Compare commits
11
Commits
1bb9d4d49d
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ed4843d76f | ||
|
|
3bb0d2b639 | ||
|
|
d8fd400cef | ||
|
|
f69350d709 | ||
|
|
bc6983a62e | ||
|
|
2010175a82 | ||
|
|
ddec2833f5 | ||
|
|
98c61a0b6f | ||
|
|
1656a94811 | ||
|
|
b00932d047 | ||
|
|
edf9d73c3e |
No files matched your search
@@ -56,6 +56,7 @@ This repository publishes the open-source server, console, integration catalog,
|
|||||||
- Keep production source files at or below 500 lines.
|
- Keep production source files at or below 500 lines.
|
||||||
- Keep functions focused on one ownership boundary.
|
- Keep functions focused on one ownership boundary.
|
||||||
- Propagate unexpected errors to the CLI or request boundary.
|
- Propagate unexpected errors to the CLI or request boundary.
|
||||||
|
- Issue API tokens only from a Better Auth session: 32 random bytes returned once and stored as a SHA-256 hash in `api_tokens`, honoring `ttl_seconds` with `null` meaning no expiry. Resolve bearer values as API tokens before session validation.
|
||||||
- Commit the operation and receipt before enqueueing. Log a failed enqueue as `operation.enqueue_failed` and still return the receipt; answer a `UNIQUE(agent_id, seq_no)` conflict with `PREV_HASH_MISMATCH` carrying the stored chain hash.
|
- Commit the operation and receipt before enqueueing. Log a failed enqueue as `operation.enqueue_failed` and still return the receipt; answer a `UNIQUE(agent_id, seq_no)` conflict with `PREV_HASH_MISMATCH` carrying the stored chain hash.
|
||||||
- Keep technical claims executable through tests or generated validation.
|
- Keep technical claims executable through tests or generated validation.
|
||||||
- Keep the mirrored Python package, distribution, and CLI version in `sdks/python/elydora/_version.py`; Setuptools metadata must read that literal dynamically.
|
- Keep the mirrored Python package, distribution, and CLI version in `sdks/python/elydora/_version.py`; Setuptools metadata must read that literal dynamically.
|
||||||
@@ -65,8 +66,8 @@ This repository publishes the open-source server, console, integration catalog,
|
|||||||
- Honor valid `Retry-After` delay-seconds and HTTP-date values, and release retryable responses before sleeping.
|
- Honor valid `Retry-After` delay-seconds and HTTP-date values, and release retryable responses before sleeping.
|
||||||
- Avoid compatibility shims without a named public contract.
|
- Avoid compatibility shims without a named public contract.
|
||||||
- Preserve the minimum runtime versions declared by each package.
|
- Preserve the minimum runtime versions declared by each package.
|
||||||
- Keep private keys and API tokens out of process arguments and generated setup commands. Accept them through hidden terminal input or owner-only credential files.
|
- Keep private keys and API tokens out of process arguments. CLIs accept them through hidden terminal input or owner-only credential files.
|
||||||
- Keep Console instruction generation limited to agent identity and public configuration. Adapter commands use hidden CLI prompts; direct SDK examples read named runtime environment variables. Expose credentials through explicit copy controls only.
|
- Console instruction generation accepts agent identity, public configuration, and the issued credentials. Native hook commands store both credentials in owner-only files, install through the SDK file flags, and delete the files; direct SDK examples read named runtime environment variables. Credential values also remain available through explicit copy controls.
|
||||||
- Persist credential-bearing files through owner-only same-directory temporary files and atomic rename.
|
- Persist credential-bearing files through owner-only same-directory temporary files and atomic rename.
|
||||||
- Read runtime config, private keys, status cache, chain state, and error logs through physical-file descriptors with identity checks. Write cache and validated chain state atomically, and append error logs through no-follow owner-only descriptors. Preserve rollback artifacts when recovery cannot safely restore an original file and include the recovery path in the surfaced error.
|
- Read runtime config, private keys, status cache, chain state, and error logs through physical-file descriptors with identity checks. Write cache and validated chain state atomically, and append error logs through no-follow owner-only descriptors. Preserve rollback artifacts when recovery cannot safely restore an original file and include the recovery path in the surfaced error.
|
||||||
- Go CLI install credentials come from terminal-echo-disabled input or physical owner-only files containing one UTF-8 line of at most 64 KiB. Runtime config, private key, and audit script form one rollback-capable transaction, and generated runtimes validate file identity, size, and Unix permissions before reads.
|
- Go CLI install credentials come from terminal-echo-disabled input or physical owner-only files containing one UTF-8 line of at most 64 KiB. Runtime config, private key, and audit script form one rollback-capable transaction, and generated runtimes validate file identity, size, and Unix permissions before reads.
|
||||||
|
|||||||
@@ -4,6 +4,21 @@ All notable changes to the Elydora Open Source project will be documented in thi
|
|||||||
|
|
||||||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
|
||||||
|
|
||||||
|
## [Unreleased]
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- Console registration wizard synced with the hosted Console: native hook catalog with activation steps, a one-command install that embeds both credentials in owner-only files (created exclusively with `mktemp` on POSIX; created under `$HOME` and protected with `chmod 600` or an `icacls` owner-only ACL in PowerShell) and deletes them afterwards, and a never-expiring default API token.
|
||||||
|
- Server issues opaque API tokens: `POST /v1/auth/token` honours `ttl_seconds` (`null` never expires), stores only SHA-256 hashes (migration 003), and resolves bearer values as API tokens before Better Auth sessions.
|
||||||
|
- Server commits operations before enqueueing; a `seq_no` race now returns `400 PREV_HASH_MISMATCH` with the stored chain hash, and every queue producer names its durable message id.
|
||||||
|
- SDK mirrors rebuilt from the standalone repositories at Node 2.1.1, Python 2.1.1, and Go v2.1.1. Python 2.1.1 is on PyPI and Go v2.1.1 is on the module proxy; npm serves 2.0.1 until the Node publish. The mirrors live under `github.com/Elydora-Infrastructure/Elydora-Go-SDK/v2` (hook retry on `PREV_HASH_MISMATCH` within a 4-second budget, durable managed-file transactions, Kiro IDE adapter, shared managed-runtime modules).
|
||||||
|
- Compose and Helm apply `packages/server/migrations` through one runner (`node dist/migrate.js`); migrations already recorded in `schema_versions` are skipped. Migration 004 restores the status constraints, the agent organization foreign key, and the 64-bit counters that the previous inline Compose schema carried.
|
||||||
|
- Server emits `AGENT_REVOKED` and `KEY_RETIRED` instead of reusing `AGENT_FROZEN` and `KEY_REVOKED`, matching the hosted API and the protocol specification.
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- SDK `deepHealth()` returns the degraded report that the API sends with HTTP 503 instead of raising.
|
||||||
|
- SDKs no longer replay non-idempotent requests after a 429, a 5xx, or a lost connection, so a retried token rotation cannot discard the replacement token.
|
||||||
|
- `RotateApiTokenResponse` carries `previous_token_grace_until`, and agent IDs are percent-encoded in request paths.
|
||||||
|
|
||||||
## [0.1.0] - 2025-03-02
|
## [0.1.0] - 2025-03-02
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|||||||
+20
-6
@@ -1,6 +1,6 @@
|
|||||||
# Contributing to Elydora
|
# Contributing to Elydora
|
||||||
|
|
||||||
Thank you for your interest in contributing to [Elydora](https://elydora.com) — 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.
|
Thank you for your interest in contributing to [Elydora](https://elydora.com), 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.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -34,7 +34,7 @@ This project follows the [Contributor Covenant](https://www.contributor-covenant
|
|||||||
| Docker | 24+ |
|
| Docker | 24+ |
|
||||||
| Docker Compose | v2.20+ |
|
| Docker Compose | v2.20+ |
|
||||||
| Node.js | 18+ (for Console / Node SDK) |
|
| Node.js | 18+ (for Console / Node SDK) |
|
||||||
| Python | 3.9+ (for Python SDK) |
|
| Python | 3.10+ (for Python SDK) |
|
||||||
| Go | 1.21+ (for Go SDK) |
|
| Go | 1.21+ (for Go SDK) |
|
||||||
|
|
||||||
### Running the API Server
|
### Running the API Server
|
||||||
@@ -50,7 +50,7 @@ This project follows the [Contributor Covenant](https://www.contributor-covenant
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
cp .env.example .env
|
cp .env.example .env
|
||||||
# Edit .env — required variables are documented inside the file
|
# Edit .env; required variables are documented inside the file
|
||||||
```
|
```
|
||||||
|
|
||||||
3. **Generate cryptographic keys**
|
3. **Generate cryptographic keys**
|
||||||
@@ -154,11 +154,11 @@ go build -o elydora ./cmd/elydora
|
|||||||
- Use strict TypeScript (`"strict": true` in tsconfig).
|
- Use strict TypeScript (`"strict": true` in tsconfig).
|
||||||
- Prefer `readonly` properties on interfaces.
|
- Prefer `readonly` properties on interfaces.
|
||||||
- Use explicit return types on exported functions and methods.
|
- Use explicit return types on exported functions and methods.
|
||||||
- No `any` types — use `unknown` and narrow explicitly.
|
- No `any` types; use `unknown` and narrow explicitly.
|
||||||
|
|
||||||
### Python SDK
|
### Python SDK
|
||||||
|
|
||||||
- Target Python 3.9+ compatibility.
|
- Target Python 3.10+ compatibility.
|
||||||
- Use type hints everywhere (checked with `mypy`).
|
- Use type hints everywhere (checked with `mypy`).
|
||||||
- Follow PEP 8 with a line length of 100.
|
- Follow PEP 8 with a line length of 100.
|
||||||
- Prefer keyword-only arguments for optional parameters.
|
- Prefer keyword-only arguments for optional parameters.
|
||||||
@@ -166,7 +166,7 @@ go build -o elydora ./cmd/elydora
|
|||||||
### Go SDK
|
### Go SDK
|
||||||
|
|
||||||
- Follow standard Go formatting (`gofmt`).
|
- Follow standard Go formatting (`gofmt`).
|
||||||
- Return `(T, error)` — never panic in library code.
|
- Return `(T, error)`; never panic in library code.
|
||||||
- Keep the public API minimal; unexported helpers start with a lowercase letter.
|
- Keep the public API minimal; unexported helpers start with a lowercase letter.
|
||||||
- Document all exported symbols with Go doc comments.
|
- Document all exported symbols with Go doc comments.
|
||||||
|
|
||||||
@@ -241,3 +241,17 @@ Elydora welcomes official and community SDKs. A conformant SDK must implement th
|
|||||||
- Use [GitHub Issues](https://github.com/Elydora-Infrastructure/Elydora-Open-Source/issues) for bug reports and feature requests.
|
- Use [GitHub Issues](https://github.com/Elydora-Infrastructure/Elydora-Open-Source/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.
|
- 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.
|
- Include as much context as possible: Elydora version, SDK version, OS, steps to reproduce, and expected vs. actual behavior.
|
||||||
|
|
||||||
|
## Opening a pull request on git.zacharyzhang.com
|
||||||
|
|
||||||
|
Forking is disabled on this server. Open pull requests with AGit instead:
|
||||||
|
|
||||||
|
1. Register an account at https://git.zacharyzhang.com
|
||||||
|
2. Clone, commit your changes, then push to a `refs/for/<target-branch>` ref:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git clone https://git.zacharyzhang.com/Elydora-Infrastructure/Elydora-Open-Source.git
|
||||||
|
git push origin HEAD:refs/for/main -o topic=<short-name> -o title="<PR title>" -o description="<PR body>"
|
||||||
|
```
|
||||||
|
|
||||||
|
3. To update the pull request, push again with the same `topic`.
|
||||||
@@ -10,9 +10,9 @@ Tamper-evident audit trails with cryptographic proof for every AI agent action.
|
|||||||
|
|
||||||
[](https://github.com/Elydora-Infrastructure/Elydora-Open-Source)
|
[](https://github.com/Elydora-Infrastructure/Elydora-Open-Source)
|
||||||
[](LICENSE)
|
[](LICENSE)
|
||||||
[](sdks/node/)
|
[](sdks/node/)
|
||||||
[](sdks/python/)
|
[](sdks/python/)
|
||||||
[](sdks/go/)
|
[](sdks/go/)
|
||||||
[](https://github.com/Elydora-Infrastructure/Elydora-Open-Source/actions)
|
[](https://github.com/Elydora-Infrastructure/Elydora-Open-Source/actions)
|
||||||
[](https://elydora.com)
|
[](https://elydora.com)
|
||||||
|
|
||||||
@@ -24,17 +24,17 @@ Tamper-evident audit trails with cryptographic proof for every AI agent action.
|
|||||||
|
|
||||||
## What is Elydora?
|
## 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.
|
Elydora is an open-source **responsibility protocol for AI agents**. When an AI agent reads a file, calls an API, modifies a database, or sends 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.
|
The result is a complete, verifiable history of everything your AI agents have ever done, that no one, including the platform operator, can silently alter.
|
||||||
|
|
||||||
**Core protocol features:**
|
**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
|
- **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
|
- **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
|
- **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
|
- **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
|
- **Compliance exports (JSON / PDF)**: one-click export of the complete audit trail for any time range, agent, or operation type
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -87,7 +87,7 @@ The result is a complete, verifiable history of everything your AI agents have e
|
|||||||
git clone https://github.com/Elydora-Infrastructure/Elydora-Open-Source.git
|
git clone https://github.com/Elydora-Infrastructure/Elydora-Open-Source.git
|
||||||
cd Elydora-Open-Source
|
cd Elydora-Open-Source
|
||||||
|
|
||||||
# One-click install — generates all secrets and starts every service
|
# One-command install: generates all secrets and starts every service
|
||||||
./scripts/install.sh
|
./scripts/install.sh
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -127,12 +127,15 @@ Elydora-Open-Source/
|
|||||||
│ ├── server/ # Elydora API server
|
│ ├── server/ # Elydora API server
|
||||||
│ └── console/ # Next.js web management console
|
│ └── console/ # Next.js web management console
|
||||||
├── sdks/
|
├── sdks/
|
||||||
│ ├── node/ # @elydora/sdk — Node.js / TypeScript SDK
|
│ ├── node/ # @elydora/sdk, Node.js and TypeScript
|
||||||
│ ├── python/ # elydora — Python SDK (sync + async)
|
│ ├── python/ # elydora, sync and async clients
|
||||||
│ └── go/ # github.com/Elydora-Infrastructure/Elydora-Go-SDK
|
│ └── go/ # github.com/Elydora-Infrastructure/Elydora-Go-SDK/v2
|
||||||
├── helm/
|
├── helm/
|
||||||
│ └── elydora/ # Kubernetes Helm chart
|
│ └── elydora/ # Kubernetes Helm chart
|
||||||
├── scripts/ # Key generation, migration, and utility scripts
|
├── integrations/ # catalog.json: provider IDs and hook contracts
|
||||||
|
├── scripts/ # Installer, key generation, MinIO bucket setup
|
||||||
|
├── test/ # Catalog contract tests
|
||||||
|
├── docker-compose.yml # One-command self-host stack
|
||||||
├── CONTRIBUTING.md
|
├── CONTRIBUTING.md
|
||||||
├── LICENSE
|
├── LICENSE
|
||||||
└── README.md
|
└── README.md
|
||||||
@@ -143,7 +146,7 @@ Elydora-Open-Source/
|
|||||||
| `packages/server` | The core API server. Handles EOR ingestion, signature verification, chain validation, RBAC, epoch rollups, and compliance exports. |
|
| `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. |
|
| `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/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/python` | Python SDK with both synchronous (`ElydoraClient`) and asynchronous (`AsyncElydoraClient`) clients. Requires Python 3.10+. |
|
||||||
| `sdks/go` | Go SDK with idiomatic error handling. Ships a CLI binary and embeds zero external runtime dependencies. |
|
| `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. |
|
| `helm/elydora` | Production-ready Helm chart for Kubernetes deployments with configurable replicas, resource limits, and secret management. |
|
||||||
|
|
||||||
@@ -207,6 +210,8 @@ Agent SDK Server
|
|||||||
|
|
||||||
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.
|
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.
|
||||||
|
|
||||||
|
This self-hosted server mounts `/.well-known/elydora/jwks.json`, `/api/auth/*`, `/v1/auth`, `/v1/agents`, `/v1/operations`, `/v1/audit`, `/v1/epochs`, and `/v1/exports`. The SDKs also expose `/v1/auth/rotate`, `/v1/health/deep`, `/v1/webhooks`, `/v1/members`, and `/v1/admin/events`, which only the hosted API implements; those methods return 404 here. SDK response types follow the hosted API, so fields such as `token_id`, `current_organization`, `onboarding_completed`, `capabilities`, `previous_status`, and the organization's `description` and `ba_org_id` can be absent in self-hosted responses.
|
||||||
|
|
||||||
Errors are returned as:
|
Errors are returned as:
|
||||||
|
|
||||||
```json
|
```json
|
||||||
@@ -259,13 +264,13 @@ Return the current session and user profile. Requires a valid session token or c
|
|||||||
|
|
||||||
#### `POST /v1/auth/token`
|
#### `POST /v1/auth/token`
|
||||||
|
|
||||||
Issue a long-lived API token.
|
Issue an opaque API token for SDK and hook use. Requires a Better Auth session; the token is returned once and only its SHA-256 hash is stored.
|
||||||
|
|
||||||
| Field | Type | Required | Description |
|
| Field | Type | Required | Description |
|
||||||
|-------|------|----------|-------------|
|
|-------|------|----------|-------------|
|
||||||
| `ttl_seconds` | integer \| null | no | Token lifetime in seconds. `null` for non-expiring. |
|
| `ttl_seconds` | integer \| null | no | Token lifetime in seconds, from 1 to 31536000. `null` or omitted for non-expiring. |
|
||||||
|
|
||||||
**Response:** `{ "token": "<api-token>", "expires_at": <ms> | null }`
|
**Response:** `{ "token": "<api-token>", "expires_at": <unix seconds> | null }`
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -287,7 +292,7 @@ Register a new agent. Required role: **integration_engineer**.
|
|||||||
"responsible_entity": "engineering-team@example.com",
|
"responsible_entity": "engineering-team@example.com",
|
||||||
"keys": [
|
"keys": [
|
||||||
{
|
{
|
||||||
"kid": "my-agent-v1-key-v1",
|
"kid": "my-agent-v1-key-1",
|
||||||
"public_key": "<base64url Ed25519 public key>",
|
"public_key": "<base64url Ed25519 public key>",
|
||||||
"algorithm": "ed25519"
|
"algorithm": "ed25519"
|
||||||
}
|
}
|
||||||
@@ -353,7 +358,7 @@ Re-activate a frozen agent. Required role: **security_admin**.
|
|||||||
|
|
||||||
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**.
|
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" }`
|
**Request body:** `{ "kid": "my-agent-v1-key-1", "reason": "Key rotation" }`
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -361,11 +366,11 @@ Revoke a specific signing key. The agent remains registered but the key can no l
|
|||||||
|
|
||||||
#### `POST /v1/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.
|
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)).
|
**Request body:** A complete EOR (see [Protocol Specification](#protocol-specification)).
|
||||||
|
|
||||||
**Response:** `{ "receipt": <EAR> }` — the server-issued Elydora Acknowledgment Receipt.
|
**Response:** `{ "receipt": <EAR> }`, the server-issued Elydora Acknowledgment Receipt.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -571,7 +576,7 @@ await client.registerAgent({
|
|||||||
agent_id: 'order-processor-v1',
|
agent_id: 'order-processor-v1',
|
||||||
integration_type: 'sdk',
|
integration_type: 'sdk',
|
||||||
display_name: 'Order Processor',
|
display_name: 'Order Processor',
|
||||||
keys: [{ kid: 'order-processor-v1-key-v1', public_key: client.getPublicKey(), algorithm: 'ed25519' }],
|
keys: [{ kid: 'order-processor-v1-key-1', public_key: client.getPublicKey(), algorithm: 'ed25519' }],
|
||||||
});
|
});
|
||||||
|
|
||||||
// 4. Submit an operation
|
// 4. Submit an operation
|
||||||
@@ -602,7 +607,7 @@ npx elydora submit-op ... # Submit an operation from the CLI
|
|||||||
pip install elydora
|
pip install elydora
|
||||||
```
|
```
|
||||||
|
|
||||||
**Requirements:** Python 3.9+, `requests`, `aiohttp`, `cryptography`
|
**Requirements:** Python 3.10+, `requests`, `aiohttp`, `cryptography`
|
||||||
|
|
||||||
**Quickstart (synchronous):**
|
**Quickstart (synchronous):**
|
||||||
|
|
||||||
@@ -633,7 +638,7 @@ client.register_agent({
|
|||||||
"agent_id": "order-processor-v1",
|
"agent_id": "order-processor-v1",
|
||||||
"integration_type": "sdk",
|
"integration_type": "sdk",
|
||||||
"display_name": "Order Processor",
|
"display_name": "Order Processor",
|
||||||
"keys": [{"kid": "order-processor-v1-key-v1",
|
"keys": [{"kid": "order-processor-v1-key-1",
|
||||||
"public_key": derive_public_key(os.environ["AGENT_PRIVATE_KEY"]),
|
"public_key": derive_public_key(os.environ["AGENT_PRIVATE_KEY"]),
|
||||||
"algorithm": "ed25519"}],
|
"algorithm": "ed25519"}],
|
||||||
})
|
})
|
||||||
@@ -675,7 +680,7 @@ elydora keygen
|
|||||||
**Installation:**
|
**Installation:**
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
go get github.com/Elydora-Infrastructure/Elydora-Go-SDK
|
go get github.com/Elydora-Infrastructure/Elydora-Go-SDK/v2
|
||||||
```
|
```
|
||||||
|
|
||||||
**Requirements:** Go 1.21+
|
**Requirements:** Go 1.21+
|
||||||
@@ -689,7 +694,7 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
|
|
||||||
elydora "github.com/Elydora-Infrastructure/Elydora-Go-SDK"
|
elydora "github.com/Elydora-Infrastructure/Elydora-Go-SDK/v2"
|
||||||
)
|
)
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
@@ -746,7 +751,7 @@ go run ./cmd/elydora keygen
|
|||||||
|
|
||||||
## Protocol Specification
|
## Protocol Specification
|
||||||
|
|
||||||
### EOR — Elydora Operation Record
|
### 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.
|
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.
|
||||||
|
|
||||||
@@ -755,7 +760,7 @@ The EOR is the fundamental unit of the protocol. It is constructed and signed by
|
|||||||
| Field | Type | Description |
|
| Field | Type | Description |
|
||||||
|-------|------|-------------|
|
|-------|------|-------------|
|
||||||
| `op_version` | `"1.0"` | Protocol version |
|
| `op_version` | `"1.0"` | Protocol version |
|
||||||
| `operation_id` | string | UUIDv7 — monotonic, time-ordered unique ID |
|
| `operation_id` | string | UUIDv7, monotonic and time-ordered |
|
||||||
| `org_id` | string | Organization identifier |
|
| `org_id` | string | Organization identifier |
|
||||||
| `agent_id` | string | Agent identifier |
|
| `agent_id` | string | Agent identifier |
|
||||||
| `issued_at` | integer | Unix timestamp in milliseconds when the EOR was constructed |
|
| `issued_at` | integer | Unix timestamp in milliseconds when the EOR was constructed |
|
||||||
@@ -797,7 +802,7 @@ If `payload` is `null`, the hash is of the JCS canonical form of `null`.
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
### EAR — Elydora Acknowledgment Receipt
|
### EAR: Elydora Acknowledgment Receipt
|
||||||
|
|
||||||
The EAR is issued by the server upon accepting a valid EOR. It constitutes server-side proof of ingestion.
|
The EAR is issued by the server upon accepting a valid EOR. It constitutes server-side proof of ingestion.
|
||||||
|
|
||||||
@@ -818,7 +823,7 @@ The EAR is issued by the server upon accepting a valid EOR. It constitutes serve
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
### EER — Elydora Epoch Record
|
### 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.
|
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.
|
||||||
|
|
||||||
@@ -874,7 +879,7 @@ Elydora is configured via environment variables. Copy `.env.example` to `.env` a
|
|||||||
|----------|---------|-------------|
|
|----------|---------|-------------|
|
||||||
| `PORT` | `8787` | HTTP port the API server listens on |
|
| `PORT` | `8787` | HTTP port the API server listens on |
|
||||||
| `EPOCH_INTERVAL_SECONDS` | `3600` | How often epoch rollups are created |
|
| `EPOCH_INTERVAL_SECONDS` | `3600` | How often epoch rollups are created |
|
||||||
| `TSA_URL` | — | RFC 3161 Trusted Timestamp Authority URL. If unset, TSA anchoring is disabled. |
|
| `TSA_URL` | none | RFC 3161 Trusted Timestamp Authority URL. If unset, TSA anchoring is disabled. |
|
||||||
| `LOG_LEVEL` | `info` | Log verbosity (`debug`, `info`, `warn`, `error`) |
|
| `LOG_LEVEL` | `info` | Log verbosity (`debug`, `info`, `warn`, `error`) |
|
||||||
| `MAX_PAYLOAD_BYTES` | `65536` | Maximum size of an EOR payload |
|
| `MAX_PAYLOAD_BYTES` | `65536` | Maximum size of an EOR payload |
|
||||||
| `OPERATION_TTL_MAX_MS` | `300000` | Maximum allowable `ttl_ms` in submitted EORs |
|
| `OPERATION_TTL_MAX_MS` | `300000` | Maximum allowable `ttl_ms` in submitted EORs |
|
||||||
@@ -886,17 +891,17 @@ Elydora is configured via environment variables. Copy `.env.example` to `.env` a
|
|||||||
|
|
||||||
### Cryptographic Guarantees
|
### 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.
|
**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.
|
**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.
|
**PBKDF2-SHA256 password hashing.** User passwords are hashed with PBKDF2-SHA256 before storage.
|
||||||
|
|
||||||
**Better Auth session-based authentication** — User sessions are managed by Better Auth with secure server-side session storage. Session tokens cannot be forged without the secret.
|
**Better Auth session-based authentication.** User sessions are managed by Better Auth with secure server-side session storage. Session 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.
|
**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.
|
**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
|
### Key Management
|
||||||
|
|
||||||
|
|||||||
+7
-165
@@ -1,4 +1,3 @@
|
|||||||
version: '3.9'
|
|
||||||
|
|
||||||
services:
|
services:
|
||||||
# ============================================================
|
# ============================================================
|
||||||
@@ -55,7 +54,7 @@ services:
|
|||||||
retries: 10
|
retries: 10
|
||||||
|
|
||||||
# ============================================================
|
# ============================================================
|
||||||
# MinIO init — creates the evidence bucket
|
# MinIO init: creates the evidence bucket
|
||||||
# ============================================================
|
# ============================================================
|
||||||
minio-init:
|
minio-init:
|
||||||
image: minio/mc:latest
|
image: minio/mc:latest
|
||||||
@@ -75,175 +74,18 @@ services:
|
|||||||
restart: on-failure
|
restart: on-failure
|
||||||
|
|
||||||
# ============================================================
|
# ============================================================
|
||||||
# DB migrate — runs PostgreSQL migrations
|
# DB migrate: applies packages/server/migrations
|
||||||
# ============================================================
|
# ============================================================
|
||||||
db-migrate:
|
db-migrate:
|
||||||
image: postgres:16-alpine
|
build:
|
||||||
|
context: ./packages/server
|
||||||
|
dockerfile: Dockerfile
|
||||||
|
command: ["node", "dist/migrate.js"]
|
||||||
|
env_file: .env
|
||||||
depends_on:
|
depends_on:
|
||||||
postgres:
|
postgres:
|
||||||
condition: service_healthy
|
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
|
|
||||||
CONSTRAINT agents_integration_type_check
|
|
||||||
CHECK (integration_type IN (
|
|
||||||
'augment', 'claudecode', 'cline', 'codex', 'copilot',
|
|
||||||
'cursor', 'droid', 'gemini', 'grok', 'kimi', 'kirocli',
|
|
||||||
'kiroide', 'letta', 'opencode', 'qwen', 'enterprise',
|
|
||||||
'gui', 'sdk', 'other'
|
|
||||||
)),
|
|
||||||
status TEXT NOT NULL DEFAULT 'active'
|
|
||||||
CHECK (status IN ('active', 'frozen', 'revoked')),
|
|
||||||
created_at BIGINT NOT NULL,
|
|
||||||
updated_at BIGINT NOT NULL
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_agents_org_id ON agents (org_id);
|
|
||||||
|
|
||||||
ALTER TABLE agents ALTER COLUMN integration_type DROP DEFAULT;
|
|
||||||
ALTER TABLE agents DROP CONSTRAINT IF EXISTS agents_integration_type_check;
|
|
||||||
ALTER TABLE agents
|
|
||||||
ADD CONSTRAINT agents_integration_type_check
|
|
||||||
CHECK (integration_type IN (
|
|
||||||
'augment', 'claudecode', 'cline', 'codex', 'copilot',
|
|
||||||
'cursor', 'droid', 'gemini', 'grok', 'kimi', 'kirocli',
|
|
||||||
'kiroide', 'letta', 'opencode', 'qwen', 'enterprise',
|
|
||||||
'gui', 'sdk', 'other'
|
|
||||||
));
|
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS agent_keys (
|
|
||||||
kid TEXT NOT NULL PRIMARY KEY,
|
|
||||||
agent_id TEXT NOT NULL REFERENCES agents (agent_id),
|
|
||||||
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;
|
|
||||||
|
|
||||||
INSERT INTO schema_versions (version, applied_at, description)
|
|
||||||
VALUES (2, EXTRACT(EPOCH FROM NOW())::BIGINT * 1000, 'Require supported agent integration type')
|
|
||||||
ON CONFLICT DO NOTHING;
|
|
||||||
"
|
|
||||||
restart: on-failure
|
|
||||||
|
|
||||||
# ============================================================
|
|
||||||
# API server
|
|
||||||
# ============================================================
|
|
||||||
api:
|
api:
|
||||||
build:
|
build:
|
||||||
context: ./packages/server
|
context: ./packages/server
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
apiVersion: v2
|
apiVersion: v2
|
||||||
name: elydora
|
name: elydora
|
||||||
description: Elydora — Open-source AI accountability and compliance platform
|
description: Open-source AI accountability and compliance platform for Elydora
|
||||||
type: application
|
type: application
|
||||||
version: 0.1.0
|
version: 0.1.0
|
||||||
appVersion: "0.1.0"
|
appVersion: "0.1.0"
|
||||||
+1
-1
@@ -2,7 +2,7 @@
|
|||||||
"name": "elydora-open-source",
|
"name": "elydora-open-source",
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"description": "Open-source responsibility layer for AI agents — tamper-evident audit trails with cryptographic proof for every action.",
|
"description": "Open-source responsibility layer for AI agents: tamper-evident audit trails with cryptographic proof for every action.",
|
||||||
"keywords": [
|
"keywords": [
|
||||||
"ai",
|
"ai",
|
||||||
"agents",
|
"agents",
|
||||||
|
|||||||
+18
-10
@@ -8,7 +8,8 @@ Web dashboard for the Elydora Responsibility Protocol. Provides agent management
|
|||||||
- **Language:** TypeScript 5.7
|
- **Language:** TypeScript 5.7
|
||||||
- **UI:** React 19, Tailwind CSS 4
|
- **UI:** React 19, Tailwind CSS 4
|
||||||
- **Data Fetching:** SWR 2.3
|
- **Data Fetching:** SWR 2.3
|
||||||
- **Deployment:** Cloudflare Pages (static export)
|
- **i18n:** i18next with English and Simplified Chinese
|
||||||
|
- **Deployment:** Docker image running the Next.js standalone server
|
||||||
|
|
||||||
## Project Structure
|
## Project Structure
|
||||||
|
|
||||||
@@ -16,7 +17,7 @@ Web dashboard for the Elydora Responsibility Protocol. Provides agent management
|
|||||||
src/
|
src/
|
||||||
├── app/
|
├── app/
|
||||||
│ ├── layout.tsx # Root layout with AppShell
|
│ ├── layout.tsx # Root layout with AppShell
|
||||||
│ ├── page.tsx # Dashboard — stats, recent operations, quick actions
|
│ ├── page.tsx # Dashboard: stats, recent operations, quick actions
|
||||||
│ ├── globals.css # Theme variables, global styles
|
│ ├── globals.css # Theme variables, global styles
|
||||||
│ ├── agents/
|
│ ├── agents/
|
||||||
│ │ ├── page.tsx # Agent list, search, registration
|
│ │ ├── page.tsx # Agent list, search, registration
|
||||||
@@ -33,6 +34,8 @@ src/
|
|||||||
│ │ └── page.tsx # Audit log query builder
|
│ │ └── page.tsx # Audit log query builder
|
||||||
│ ├── login/page.tsx # Login form
|
│ ├── login/page.tsx # Login form
|
||||||
│ ├── register/page.tsx # Registration form
|
│ ├── register/page.tsx # Registration form
|
||||||
|
│ ├── settings/ # Organization settings
|
||||||
|
│ ├── global-error.tsx # Root error boundary
|
||||||
│ └── jwks/page.tsx # Public key display
|
│ └── jwks/page.tsx # Public key display
|
||||||
├── components/
|
├── components/
|
||||||
│ ├── AppShell.tsx # Main layout wrapper with sidebar
|
│ ├── AppShell.tsx # Main layout wrapper with sidebar
|
||||||
@@ -43,6 +46,8 @@ src/
|
|||||||
│ ├── EpochDetailClient.tsx # Epoch proof and verification
|
│ ├── EpochDetailClient.tsx # Epoch proof and verification
|
||||||
│ ├── ChainVisualization.tsx # Merkle chain visualization
|
│ ├── ChainVisualization.tsx # Merkle chain visualization
|
||||||
│ ├── VerificationChecklist.tsx # Proof verification UI
|
│ ├── VerificationChecklist.tsx # Proof verification UI
|
||||||
|
│ ├── I18nProvider.tsx # Locale provider
|
||||||
|
│ ├── agent-registration/ # Registration flow components
|
||||||
│ └── ui/
|
│ └── ui/
|
||||||
│ ├── PageHeader.tsx # Page title and breadcrumbs
|
│ ├── PageHeader.tsx # Page title and breadcrumbs
|
||||||
│ ├── DataTable.tsx # Sortable, paginated table
|
│ ├── DataTable.tsx # Sortable, paginated table
|
||||||
@@ -50,12 +55,17 @@ src/
|
|||||||
│ ├── SearchInput.tsx # Search/filter input
|
│ ├── SearchInput.tsx # Search/filter input
|
||||||
│ ├── StatusBadge.tsx # Status indicator
|
│ ├── StatusBadge.tsx # Status indicator
|
||||||
│ ├── CopyButton.tsx # Copy-to-clipboard
|
│ ├── CopyButton.tsx # Copy-to-clipboard
|
||||||
|
│ ├── LanguageDropdown.tsx # Locale switcher
|
||||||
│ └── Sidebar.tsx # Navigation sidebar
|
│ └── Sidebar.tsx # Navigation sidebar
|
||||||
|
├── features/
|
||||||
|
│ └── agent-registration/ # Install instructions and credential handling
|
||||||
|
├── i18n/ # en and zh message catalogs
|
||||||
├── lib/
|
├── lib/
|
||||||
│ ├── api.ts # Typed REST client for all endpoints
|
│ ├── api.ts # Typed REST client for all endpoints
|
||||||
│ ├── auth.tsx # Auth context and hooks
|
│ ├── auth.tsx # Auth context and hooks
|
||||||
│ └── hooks.ts # SWR data-fetching hooks
|
│ └── hooks.ts # SWR data-fetching hooks
|
||||||
└── shared/ # Shared types (@elydora/shared)
|
└── shared/ # Shared types (@elydora/shared)
|
||||||
|
e2e/ # Playwright specs
|
||||||
```
|
```
|
||||||
|
|
||||||
## Getting Started
|
## Getting Started
|
||||||
@@ -90,20 +100,17 @@ Runs at `http://localhost:3000` with Turbopack.
|
|||||||
npm run build
|
npm run build
|
||||||
```
|
```
|
||||||
|
|
||||||
Produces a static export in `out/`. A post-build script removes stale `_redirects` files.
|
Produces a Next.js standalone server in `.next/standalone`.
|
||||||
|
|
||||||
### Deployment
|
### Deployment
|
||||||
|
|
||||||
```bash
|
The root `docker-compose.yml` builds this package with `packages/console/Dockerfile` and serves it on port 3000. The image runs `node server.js` from the standalone output.
|
||||||
npm run deploy
|
|
||||||
```
|
|
||||||
|
|
||||||
Deploys the `out/` directory to Cloudflare Pages.
|
### Checks
|
||||||
|
|
||||||
### Type Checking
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
npm run typecheck
|
npm run typecheck # tsc --noEmit
|
||||||
|
npm run test:e2e # Playwright specs in e2e/
|
||||||
```
|
```
|
||||||
|
|
||||||
## Pages
|
## Pages
|
||||||
@@ -122,3 +129,4 @@ npm run typecheck
|
|||||||
| `/login` | Login |
|
| `/login` | Login |
|
||||||
| `/register` | Register account and organization |
|
| `/register` | Register account and organization |
|
||||||
| `/jwks` | Display server public keys in JWK format |
|
| `/jwks` | Display server public keys in JWK format |
|
||||||
|
| `/settings` | Organization settings |
|
||||||
@@ -4,6 +4,8 @@ import {
|
|||||||
INTEGRATION_CATALOG,
|
INTEGRATION_CATALOG,
|
||||||
INTEGRATION_TYPES,
|
INTEGRATION_TYPES,
|
||||||
} from '@/features/agent-registration/integrations';
|
} from '@/features/agent-registration/integrations';
|
||||||
|
import enRegistration from '@/i18n/en.json';
|
||||||
|
import zhRegistration from '@/i18n/zh.json';
|
||||||
import {
|
import {
|
||||||
assertRegistrationResponse,
|
assertRegistrationResponse,
|
||||||
RegistrationContractError,
|
RegistrationContractError,
|
||||||
@@ -43,35 +45,161 @@ test('keeps the complete integration catalog in canonical order', () => {
|
|||||||
expect(INTEGRATION_TYPES).toEqual(EXPECTED_INTEGRATIONS);
|
expect(INTEGRATION_TYPES).toEqual(EXPECTED_INTEGRATIONS);
|
||||||
expect(INTEGRATION_CATALOG.map(({ id }) => id)).toEqual(EXPECTED_INTEGRATIONS);
|
expect(INTEGRATION_CATALOG.map(({ id }) => id)).toEqual(EXPECTED_INTEGRATIONS);
|
||||||
expect(new Set(INTEGRATION_CATALOG.map(({ id }) => id)).size).toBe(19);
|
expect(new Set(INTEGRATION_CATALOG.map(({ id }) => id)).size).toBe(19);
|
||||||
expect(INTEGRATION_CATALOG.filter(({ mode }) => mode === 'adapter')).toHaveLength(15);
|
expect(INTEGRATION_CATALOG.filter(({ mode }) => mode === 'hooks')).toHaveLength(15);
|
||||||
expect(INTEGRATION_CATALOG.filter(({ mode }) => mode === 'sdk')).toHaveLength(4);
|
expect(INTEGRATION_CATALOG.filter(({ mode }) => mode === 'sdk')).toHaveLength(4);
|
||||||
|
expect(Object.fromEntries(INTEGRATION_CATALOG.map(({ id, name }) => [id, name])))
|
||||||
|
.toMatchObject({
|
||||||
|
codex: 'Codex CLI',
|
||||||
|
cursor: 'Cursor CLI',
|
||||||
|
grok: 'Grok Build',
|
||||||
|
kimi: 'Kimi Code',
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
test('generates every adapter command with the verified SDK flag contract', () => {
|
test('keeps every native hook activation plan aligned with its provider contract', () => {
|
||||||
for (const integration of INTEGRATION_CATALOG.filter(({ mode }) => mode === 'adapter')) {
|
const plans = Object.fromEntries(
|
||||||
|
INTEGRATION_CATALOG
|
||||||
|
.filter(({ mode }) => mode === 'hooks')
|
||||||
|
.map(({ id, postInstall }) => [id, postInstall]),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(plans).toEqual({
|
||||||
|
augment: {
|
||||||
|
kind: 'sequence',
|
||||||
|
steps: [{ key: 'startSession', command: 'auggie' }],
|
||||||
|
},
|
||||||
|
claudecode: {
|
||||||
|
kind: 'sequence',
|
||||||
|
steps: [{ key: 'openHooks', command: '/hooks' }],
|
||||||
|
},
|
||||||
|
cline: {
|
||||||
|
kind: 'sequence',
|
||||||
|
steps: [{ key: 'startTask' }],
|
||||||
|
},
|
||||||
|
codex: {
|
||||||
|
kind: 'sequence',
|
||||||
|
steps: [{ key: 'reviewAndTrustHooks', command: '/hooks' }],
|
||||||
|
},
|
||||||
|
copilot: {
|
||||||
|
kind: 'sequence',
|
||||||
|
steps: [
|
||||||
|
{ key: 'restartSession', command: '/restart' },
|
||||||
|
{ key: 'inspectEnvironment', command: '/env' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
cursor: {
|
||||||
|
kind: 'sequence',
|
||||||
|
steps: [{ key: 'inspectCursorHooks' }],
|
||||||
|
},
|
||||||
|
droid: {
|
||||||
|
kind: 'sequence',
|
||||||
|
steps: [{ key: 'openHooks', command: '/hooks' }],
|
||||||
|
},
|
||||||
|
gemini: {
|
||||||
|
kind: 'sequence',
|
||||||
|
steps: [{ key: 'listHooks', command: '/hooks list' }],
|
||||||
|
},
|
||||||
|
grok: {
|
||||||
|
kind: 'sequence',
|
||||||
|
steps: [{ key: 'inspectGrokConfig', command: 'grok inspect --json' }],
|
||||||
|
},
|
||||||
|
kimi: {
|
||||||
|
kind: 'sequence',
|
||||||
|
steps: [{ key: 'startSession', command: 'kimi' }],
|
||||||
|
},
|
||||||
|
kirocli: {
|
||||||
|
kind: 'alternatives',
|
||||||
|
steps: [
|
||||||
|
{ key: 'launchKiroV2', command: 'kiro-cli --agent elydora-audit', variant: 'V2' },
|
||||||
|
{ key: 'launchKiroV3', command: 'kiro-cli --v3', variant: 'V3' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
kiroide: {
|
||||||
|
kind: 'sequence',
|
||||||
|
steps: [{ key: 'inspectKiroIdeHooks' }],
|
||||||
|
},
|
||||||
|
letta: {
|
||||||
|
kind: 'sequence',
|
||||||
|
steps: [{ key: 'openHooks', command: '/hooks' }],
|
||||||
|
},
|
||||||
|
opencode: {
|
||||||
|
kind: 'sequence',
|
||||||
|
steps: [{ key: 'startSession', command: 'opencode' }],
|
||||||
|
},
|
||||||
|
qwen: {
|
||||||
|
kind: 'sequence',
|
||||||
|
steps: [{ key: 'listHooks', command: '/hooks list' }],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('translates every activation step in English and Chinese', () => {
|
||||||
|
const stepKeys = [...new Set(
|
||||||
|
INTEGRATION_CATALOG.flatMap(({ postInstall }) => (
|
||||||
|
postInstall?.steps.map(({ key }) => key) ?? []
|
||||||
|
)),
|
||||||
|
)].sort();
|
||||||
|
|
||||||
|
expect(Object.keys(enRegistration.agentRegistration.postInstallSteps).sort()).toEqual(stepKeys);
|
||||||
|
expect(Object.keys(zhRegistration.agentRegistration.postInstallSteps).sort()).toEqual(stepKeys);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('generates every native hook command with the verified SDK flag contract', () => {
|
||||||
|
for (const integration of INTEGRATION_CATALOG.filter(({ mode }) => mode === 'hooks')) {
|
||||||
for (const language of SDK_LANGUAGES) {
|
for (const language of SDK_LANGUAGES) {
|
||||||
const instructions = buildInstallInstructions(language, {
|
const instructions = buildInstallInstructions(language, {
|
||||||
integration,
|
integration,
|
||||||
identity: credentials,
|
identity: credentials,
|
||||||
baseUrl: 'https://api.example.com',
|
baseUrl: 'https://api.example.com',
|
||||||
|
privateKey: 'private-contract-key',
|
||||||
|
token: 'token-contract',
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(instructions.setup).toContain(`--agent ${integration.id}`);
|
expect(instructions.setup).toContain(`--agent ${integration.id}`);
|
||||||
expect(instructions.setup).toContain("'https://api.example.com'");
|
expect(instructions.setup).toContain("'https://api.example.com'");
|
||||||
expect(instructions.setup).not.toContain('private-key');
|
expect(instructions.setup).toContain("printf '%s' 'k=$(mktemp \"$HOME/.elydora-agent-contract.XXXXXX\") && trap '\\''rm -f \"$k\"'\\'' EXIT INT TERM && t=$(mktemp \"$HOME/.elydora-agent-contract.XXXXXX\") && trap '\\''rm -f \"$k\" \"$t\"'\\'' EXIT INT TERM && ");
|
||||||
expect(instructions.setup).not.toContain('private_key');
|
expect(instructions.setup).toContain("printf '\\''%s\\n'\\'' '\\''private-contract-key'\\'' > \"$k\"");
|
||||||
expect(instructions.setup).not.toContain('--token');
|
expect(instructions.setup).toContain("printf '\\''%s\\n'\\'' '\\''token-contract'\\'' > \"$t\"");
|
||||||
expect(instructions.setup).not.toContain('token-contract');
|
expect(instructions.setup).not.toContain('chmod');
|
||||||
expect(instructions.secretDelivery).toBe('hidden-prompts');
|
expect(instructions.setup.endsWith("' | sh")).toBe(true);
|
||||||
|
expect(instructions.secretDelivery).toBe('embedded');
|
||||||
|
expect(instructions.postInstall).toEqual(integration.postInstall);
|
||||||
|
|
||||||
if (language === 'go') {
|
if (language === 'go') {
|
||||||
expect(instructions.setup).toContain("--org-id 'org-contract'");
|
expect(instructions.setup).toContain("--org-id '\\''org-contract'\\''");
|
||||||
expect(instructions.setup).toContain("--agent-id 'agent-contract'");
|
expect(instructions.setup).toContain("--agent-id '\\''agent-contract'\\''");
|
||||||
expect(instructions.setup).toContain("--base-url 'https://api.example.com'");
|
expect(instructions.setup).toContain("--base-url '\\''https://api.example.com'\\''");
|
||||||
|
expect(instructions.setup).toContain('--private-key-file "$k" --token-file "$t"');
|
||||||
} else {
|
} else {
|
||||||
expect(instructions.setup).toContain("--org_id 'org-contract'");
|
expect(instructions.setup).toContain("--org_id '\\''org-contract'\\''");
|
||||||
expect(instructions.setup).toContain("--agent_id 'agent-contract'");
|
expect(instructions.setup).toContain("--agent_id '\\''agent-contract'\\''");
|
||||||
expect(instructions.setup).toContain("--base_url 'https://api.example.com'");
|
expect(instructions.setup).toContain("--base_url '\\''https://api.example.com'\\''");
|
||||||
|
expect(instructions.setup).toContain('--private_key_file "$k" --token_file "$t"');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('generates PowerShell native hook commands with the same SDK flag contract', () => {
|
||||||
|
for (const integration of INTEGRATION_CATALOG.filter(({ mode }) => mode === 'hooks')) {
|
||||||
|
for (const language of SDK_LANGUAGES) {
|
||||||
|
const instructions = buildInstallInstructions(language, {
|
||||||
|
integration,
|
||||||
|
identity: credentials,
|
||||||
|
baseUrl: 'https://api.example.com',
|
||||||
|
privateKey: 'private-contract-key',
|
||||||
|
token: 'token-contract',
|
||||||
|
}, 'powershell');
|
||||||
|
|
||||||
|
expect(instructions.setup).toContain(`--agent ${integration.id}`);
|
||||||
|
expect(instructions.setup).toContain("$k = Join-Path $HOME '.elydora-agent-contract.key'; $t = Join-Path $HOME '.elydora-agent-contract.token'; try { New-Item -ItemType File -Force -Path $k, $t | Out-Null; if ($IsLinux -or $IsMacOS) { chmod 600 $k $t; if ($LASTEXITCODE -ne 0) { throw \"chmod failed\" } } else { $u = [System.Security.Principal.WindowsIdentity]::GetCurrent().User.Value; foreach ($f in $k, $t) { icacls $f /inheritance:r /grant:r \"*$($u):F\" | Out-Null; if ($LASTEXITCODE -ne 0) { throw \"icacls failed for $f\" } } }; Set-Content -LiteralPath $k -Value 'private-contract-key'; Set-Content -LiteralPath $t -Value 'token-contract'; ");
|
||||||
|
expect(instructions.setup.endsWith('; if ($LASTEXITCODE -ne 0) { throw "elydora install exited with code $LASTEXITCODE" } } finally { Remove-Item -LiteralPath $k, $t -Force -ErrorAction SilentlyContinue }')).toBe(true);
|
||||||
|
expect(instructions.secretDelivery).toBe('embedded');
|
||||||
|
|
||||||
|
if (language === 'go') {
|
||||||
|
expect(instructions.setup).toContain("--org-id 'org-contract' --agent-id 'agent-contract' --kid 'agent-contract-key-1' --base-url 'https://api.example.com' --private-key-file $k --token-file $t;");
|
||||||
|
} else {
|
||||||
|
expect(instructions.setup).toContain("--org_id 'org-contract' --agent_id 'agent-contract' --kid 'agent-contract-key-1' --base_url 'https://api.example.com' --private_key_file $k --token_file $t;");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -86,11 +214,13 @@ test('generates direct SDK setup and operation code for each language', () => {
|
|||||||
integration: integration!,
|
integration: integration!,
|
||||||
identity: credentials,
|
identity: credentials,
|
||||||
baseUrl: 'https://api.example.com',
|
baseUrl: 'https://api.example.com',
|
||||||
|
privateKey: 'private-contract-key',
|
||||||
|
token: 'token-contract',
|
||||||
});
|
});
|
||||||
expect(instructions.setup).toContain('agent-contract');
|
expect(instructions.setup).toContain('agent-contract');
|
||||||
expect(instructions.setup).toContain('ELYDORA_PRIVATE_KEY');
|
expect(instructions.setup).toContain('ELYDORA_PRIVATE_KEY');
|
||||||
expect(instructions.setup).toContain('ELYDORA_API_TOKEN');
|
expect(instructions.setup).toContain('ELYDORA_API_TOKEN');
|
||||||
expect(instructions.setup).not.toContain('private-key');
|
expect(instructions.setup).not.toContain('private-contract-key');
|
||||||
expect(instructions.setup).not.toContain('token-contract');
|
expect(instructions.setup).not.toContain('token-contract');
|
||||||
expect(instructions.usage).toContain('ai.tool_use');
|
expect(instructions.usage).toContain('ai.tool_use');
|
||||||
expect(instructions.verify).toBeUndefined();
|
expect(instructions.verify).toBeUndefined();
|
||||||
@@ -110,6 +240,8 @@ test('quotes shell metacharacters and rejects unsupported command values', () =>
|
|||||||
orgId: '$(Write-Output injected)',
|
orgId: '$(Write-Output injected)',
|
||||||
},
|
},
|
||||||
baseUrl: 'https://api.example.com/`whoami`',
|
baseUrl: 'https://api.example.com/`whoami`',
|
||||||
|
privateKey: 'private-contract-key',
|
||||||
|
token: 'token-contract',
|
||||||
});
|
});
|
||||||
expect(instructions.setup).toContain("'$(Write-Output injected)'");
|
expect(instructions.setup).toContain("'$(Write-Output injected)'");
|
||||||
expect(instructions.setup).toContain("'https://api.example.com/`whoami`'");
|
expect(instructions.setup).toContain("'https://api.example.com/`whoami`'");
|
||||||
@@ -118,7 +250,23 @@ test('quotes shell metacharacters and rejects unsupported command values', () =>
|
|||||||
integration: integration!,
|
integration: integration!,
|
||||||
identity: { ...credentials, orgId: "org-'unsafe" },
|
identity: { ...credentials, orgId: "org-'unsafe" },
|
||||||
baseUrl: 'https://api.example.com',
|
baseUrl: 'https://api.example.com',
|
||||||
|
privateKey: 'private-contract-key',
|
||||||
|
token: 'token-contract',
|
||||||
})).toThrow('Organization ID cannot be represented');
|
})).toThrow('Organization ID cannot be represented');
|
||||||
|
expect(() => buildInstallInstructions('node', {
|
||||||
|
integration: integration!,
|
||||||
|
identity: credentials,
|
||||||
|
baseUrl: 'https://api.example.com',
|
||||||
|
privateKey: "key-'unsafe",
|
||||||
|
token: 'token-contract',
|
||||||
|
})).toThrow('Private key cannot be represented');
|
||||||
|
expect(() => buildInstallInstructions('node', {
|
||||||
|
integration: integration!,
|
||||||
|
identity: { ...credentials, agentId: 'agent contract' },
|
||||||
|
baseUrl: 'https://api.example.com',
|
||||||
|
privateKey: 'private-contract-key',
|
||||||
|
token: 'token-contract',
|
||||||
|
})).toThrow('Agent ID cannot be used as a credential file name.');
|
||||||
});
|
});
|
||||||
|
|
||||||
test('classifies malformed successful responses as blocking contract failures', () => {
|
test('classifies malformed successful responses as blocking contract failures', () => {
|
||||||
|
|||||||
@@ -110,7 +110,7 @@ async function mockBackend(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
test('registers Grok atomically and renders verified adapter commands', async ({ page }) => {
|
test('registers Grok atomically and renders verified native hook commands', async ({ page }) => {
|
||||||
const observed: ObservedRequest[] = [];
|
const observed: ObservedRequest[] = [];
|
||||||
const unexpected: string[] = [];
|
const unexpected: string[] = [];
|
||||||
const runtimeErrors: string[] = [];
|
const runtimeErrors: string[] = [];
|
||||||
@@ -123,10 +123,26 @@ test('registers Grok atomically and renders verified adapter commands', async ({
|
|||||||
await page.goto('/agents');
|
await page.goto('/agents');
|
||||||
await page.getByRole('button', { name: 'Register Agent' }).click();
|
await page.getByRole('button', { name: 'Register Agent' }).click();
|
||||||
await waitForRenderedDialog(page);
|
await waitForRenderedDialog(page);
|
||||||
await expect(page.getByRole('button', { name: 'Augment Code' })).toBeVisible();
|
const dialog = page.getByRole('dialog');
|
||||||
await expect(page.getByRole('button', { name: 'OpenAI Codex' })).toBeVisible();
|
await dialog.focus();
|
||||||
await expect(page.getByRole('button', { name: 'Kimi CLI' })).toBeVisible();
|
await page.keyboard.press('Tab');
|
||||||
await expect(page.getByRole('button', { name: 'Qwen Code' })).toBeVisible();
|
await page.keyboard.press('Tab');
|
||||||
|
const firstIntegration = page.getByRole('button', { name: 'Augment Code' });
|
||||||
|
await expect(firstIntegration).toBeFocused();
|
||||||
|
expect(await firstIntegration.evaluate((element) => {
|
||||||
|
const style = window.getComputedStyle(element);
|
||||||
|
return { width: style.outlineWidth, style: style.outlineStyle };
|
||||||
|
})).toEqual({ width: '2px', style: 'solid' });
|
||||||
|
const hookGroup = page.getByRole('region', { name: 'Native hooks' });
|
||||||
|
await expect(hookGroup.getByRole('button')).toHaveCount(15);
|
||||||
|
await expect(hookGroup.getByText('HOOKS', { exact: true })).toHaveCount(15);
|
||||||
|
await expect(hookGroup.getByRole('button', { name: 'Codex CLI' })).toBeVisible();
|
||||||
|
await expect(hookGroup.getByRole('button', { name: 'Kimi Code' })).toBeVisible();
|
||||||
|
await expect(hookGroup.getByRole('button', { name: 'Grok Build' })).toBeVisible();
|
||||||
|
await expect(hookGroup.getByRole('button', { name: 'Cursor CLI' })).toBeVisible();
|
||||||
|
const sdkGroup = page.getByRole('region', { name: 'Custom SDK' });
|
||||||
|
await expect(sdkGroup.getByRole('button')).toHaveCount(4);
|
||||||
|
await expect(sdkGroup.getByText('HOOKS', { exact: true })).toHaveCount(0);
|
||||||
await page.screenshot({ path: 'test-results/agent-registration-catalog.png', fullPage: true });
|
await page.screenshot({ path: 'test-results/agent-registration-catalog.png', fullPage: true });
|
||||||
await page.getByRole('button', { name: 'Grok Build' }).click();
|
await page.getByRole('button', { name: 'Grok Build' }).click();
|
||||||
await page.getByLabel('Display Name').fill('Production Grok');
|
await page.getByLabel('Display Name').fill('Production Grok');
|
||||||
@@ -151,29 +167,58 @@ test('registers Grok atomically and renders verified adapter commands', async ({
|
|||||||
|
|
||||||
const setup = page.locator('pre');
|
const setup = page.locator('pre');
|
||||||
await expect(setup).toContainText('npx @elydora/sdk install --agent grok');
|
await expect(setup).toContainText('npx @elydora/sdk install --agent grok');
|
||||||
await expect(setup).toContainText(`--org_id '${ORG_ID}'`);
|
await expect(setup).toContainText(`--org_id '\\''${ORG_ID}'\\''`);
|
||||||
await expect(setup).not.toContainText(TOKEN);
|
await expect(setup).toContainText(`'${TOKEN}'`);
|
||||||
await expect(setup).not.toContainText(privateKey);
|
await expect(setup).toContainText(`'${privateKey}'`);
|
||||||
await expect(setup).not.toContainText('--token');
|
await expect(setup).toContainText('mktemp "$HOME/.elydora-');
|
||||||
await expect(setup).not.toContainText('--private_key');
|
await expect(setup).toContainText('--private_key_file "$k" --token_file "$t"');
|
||||||
await expect(page.getByText('The CLI requests both values with terminal echo disabled.')).toBeVisible();
|
await expect(page.getByText(
|
||||||
|
'The command stores both credentials in owner-only files, installs the hook, then deletes them.',
|
||||||
|
)).toBeVisible();
|
||||||
|
await expect(page.getByText(
|
||||||
|
'Confirm the Elydora PreToolUse, PostToolUse, and PostToolUseFailure hooks.',
|
||||||
|
)).toBeVisible();
|
||||||
|
await expect(page.getByText('grok inspect --json', { exact: true })).toBeVisible();
|
||||||
|
|
||||||
await page.context().grantPermissions(['clipboard-read', 'clipboard-write']);
|
await page.context().grantPermissions(['clipboard-read', 'clipboard-write']);
|
||||||
await page.getByRole('button', { name: 'Copy setup command' }).click();
|
await page.getByRole('button', { name: 'Copy setup command' }).click();
|
||||||
await expect(page.getByRole('button', { name: 'Copy setup command' })).toHaveText('Copied');
|
await expect(page.getByRole('button', { name: 'Copy setup command' })).toHaveText('Copied');
|
||||||
const copiedSetup = await page.evaluate(() => navigator.clipboard.readText());
|
const copiedSetup = await page.evaluate(() => navigator.clipboard.readText());
|
||||||
expect(copiedSetup).not.toContain(TOKEN);
|
expect(copiedSetup).toContain(TOKEN);
|
||||||
expect(copiedSetup).not.toContain(privateKey);
|
expect(copiedSetup).toContain(privateKey);
|
||||||
|
const activationCopy = page.getByRole('button', {
|
||||||
|
name: 'Copy activation command: grok inspect --json',
|
||||||
|
});
|
||||||
|
await activationCopy.click();
|
||||||
|
await expect(activationCopy).toHaveText('Copied');
|
||||||
|
expect(await page.evaluate(() => navigator.clipboard.readText())).toBe('grok inspect --json');
|
||||||
|
|
||||||
await page.getByRole('tab', { name: 'go' }).click();
|
await page.getByRole('tab', { name: 'go' }).click();
|
||||||
await expect(setup).toContainText('elydora install --agent grok');
|
await expect(setup).toContainText('elydora install --agent grok');
|
||||||
await expect(setup).toContainText(`--org-id '${ORG_ID}'`);
|
await expect(setup).toContainText(`--org-id '\\''${ORG_ID}'\\''`);
|
||||||
await expect(setup).toContainText("--agent-id 'agent-");
|
await expect(setup).toContainText("--agent-id '\\''agent-");
|
||||||
await expect(setup).not.toContainText('--private-key');
|
await expect(setup).toContainText('--private-key-file "$k" --token-file "$t"');
|
||||||
await expect(setup).not.toContainText('--token');
|
await page.getByRole('tab', { name: 'PowerShell' }).click();
|
||||||
|
await expect(setup).toContainText("$k = Join-Path $HOME '.elydora-agent-");
|
||||||
|
await expect(setup).toContainText('--private-key-file $k --token-file $t;');
|
||||||
|
await page.getByRole('tab', { name: 'sh', exact: true }).click();
|
||||||
|
await expect(setup).toContainText("printf '%s' 'k=$(mktemp ");
|
||||||
|
|
||||||
await page.screenshot({ path: 'test-results/agent-registration-grok.png', fullPage: true });
|
await page.screenshot({ path: 'test-results/agent-registration-grok.png', fullPage: true });
|
||||||
|
|
||||||
|
await page.setViewportSize({ width: 390, height: 844 });
|
||||||
|
await page.getByRole('dialog').locator(':scope > div').last().evaluate(
|
||||||
|
(content) => content.scrollTo(0, content.scrollHeight),
|
||||||
|
);
|
||||||
|
await expect(page.getByText('grok inspect --json', { exact: true })).toBeVisible();
|
||||||
|
await expect(page.getByRole('button', {
|
||||||
|
name: 'Copy activation command: grok inspect --json',
|
||||||
|
})).toBeVisible();
|
||||||
|
expect(await page.evaluate(
|
||||||
|
() => document.documentElement.scrollWidth > window.innerWidth,
|
||||||
|
)).toBe(false);
|
||||||
|
await page.screenshot({ path: 'test-results/agent-registration-grok-mobile.png', fullPage: true });
|
||||||
|
|
||||||
const registrationRequests = observed.filter(({ path }) => path === '/v1/agents/register');
|
const registrationRequests = observed.filter(({ path }) => path === '/v1/agents/register');
|
||||||
expect(registrationRequests).toHaveLength(1);
|
expect(registrationRequests).toHaveLength(1);
|
||||||
expect(registrationRequests[0]?.method).toBe('POST');
|
expect(registrationRequests[0]?.method).toBe('POST');
|
||||||
@@ -249,6 +294,40 @@ test('renders custom SDK setup with runtime secret references', async ({ page })
|
|||||||
expect(runtimeErrors).toEqual([]);
|
expect(runtimeErrors).toEqual([]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('renders Kiro CLI versions as activation alternatives', async ({ page }) => {
|
||||||
|
const observed: ObservedRequest[] = [];
|
||||||
|
const unexpected: string[] = [];
|
||||||
|
const runtimeErrors: string[] = [];
|
||||||
|
page.on('pageerror', (error) => runtimeErrors.push(error.message));
|
||||||
|
page.on('console', (message) => {
|
||||||
|
if (message.type() === 'error') runtimeErrors.push(message.text());
|
||||||
|
});
|
||||||
|
await mockBackend(page, observed, unexpected);
|
||||||
|
|
||||||
|
await page.goto('/agents');
|
||||||
|
await page.getByRole('button', { name: 'Register Agent' }).click();
|
||||||
|
await page.getByRole('button', { name: 'Kiro CLI' }).click();
|
||||||
|
await page.getByLabel('Display Name').fill('Production Kiro');
|
||||||
|
await page.locator('form').getByRole('button', { name: 'Register Agent' }).click();
|
||||||
|
await page.getByRole('button', { name: 'Issue API Token' }).click();
|
||||||
|
await expect(page.getByText(TOKEN)).toBeVisible();
|
||||||
|
await page.getByRole('button', { name: 'Continue' }).click();
|
||||||
|
|
||||||
|
const alternatives = page.getByRole('list', { name: 'Post-install steps' });
|
||||||
|
await expect(alternatives).toHaveJSProperty('tagName', 'UL');
|
||||||
|
await expect(alternatives.locator('li')).toHaveCount(2);
|
||||||
|
await expect(alternatives.getByText('V2', { exact: true })).toBeVisible();
|
||||||
|
await expect(alternatives.getByText('V3', { exact: true })).toBeVisible();
|
||||||
|
await expect(alternatives.getByText('kiro-cli --agent elydora-audit', { exact: true })).toBeVisible();
|
||||||
|
await expect(alternatives.getByText('kiro-cli --v3', { exact: true })).toBeVisible();
|
||||||
|
await page.screenshot({ path: 'test-results/agent-registration-kiro.png', fullPage: true });
|
||||||
|
|
||||||
|
expect(observed.filter(({ path }) => path === '/v1/agents/register')).toHaveLength(1);
|
||||||
|
expect(observed.filter(({ path }) => path === '/v1/auth/token')).toHaveLength(1);
|
||||||
|
expect(unexpected).toEqual([]);
|
||||||
|
expect(runtimeErrors).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
test('loads the Chinese registration resources', async ({ page }) => {
|
test('loads the Chinese registration resources', async ({ page }) => {
|
||||||
const observed: ObservedRequest[] = [];
|
const observed: ObservedRequest[] = [];
|
||||||
const unexpected: string[] = [];
|
const unexpected: string[] = [];
|
||||||
@@ -263,11 +342,21 @@ test('loads the Chinese registration resources', async ({ page }) => {
|
|||||||
await page.goto('/agents');
|
await page.goto('/agents');
|
||||||
await expect(page.locator('html')).toHaveAttribute('lang', 'zh');
|
await expect(page.locator('html')).toHaveAttribute('lang', 'zh');
|
||||||
await page.getByRole('button', { name: '注册代理' }).click();
|
await page.getByRole('button', { name: '注册代理' }).click();
|
||||||
await expect(page.getByText('CLI 适配器')).toBeVisible();
|
await expect(page.getByText('原生 Hooks')).toBeVisible();
|
||||||
await expect(page.getByText('自定义 SDK')).toBeVisible();
|
await expect(page.getByText('自定义 SDK')).toBeVisible();
|
||||||
await expect(page.getByRole('button', { name: 'OpenAI Codex' })).toBeVisible();
|
await expect(page.getByRole('button', { name: 'Codex CLI' })).toBeVisible();
|
||||||
|
await page.getByRole('button', { name: 'Kimi Code' }).click();
|
||||||
|
await page.getByLabel('显示名称').fill('生产 Kimi');
|
||||||
|
await page.locator('form').getByRole('button', { name: '注册代理' }).click();
|
||||||
|
await page.getByRole('button', { name: '签发 API 令牌' }).click();
|
||||||
|
await expect(page.getByText(TOKEN)).toBeVisible();
|
||||||
|
await page.getByRole('button', { name: '继续' }).click();
|
||||||
|
await expect(page.getByText('启动新的 Kimi Code 会话。')).toBeVisible();
|
||||||
|
await expect(page.getByText('kimi', { exact: true })).toBeVisible();
|
||||||
|
|
||||||
expect(observed.filter(({ path }) => path === '/v1/agents')).toHaveLength(1);
|
expect(observed.filter(({ path }) => path === '/v1/agents')).toHaveLength(2);
|
||||||
|
expect(observed.filter(({ path }) => path === '/v1/agents/register')).toHaveLength(1);
|
||||||
|
expect(observed.filter(({ path }) => path === '/v1/auth/token')).toHaveLength(1);
|
||||||
expect(unexpected).toEqual([]);
|
expect(unexpected).toEqual([]);
|
||||||
expect(runtimeErrors).toEqual([]);
|
expect(runtimeErrors).toEqual([]);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -353,3 +353,21 @@ body {
|
|||||||
.check-fail {
|
.check-fail {
|
||||||
color: #dc2626;
|
color: #dc2626;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Focus-visible styles for keyboard navigation (WCAG 2.1 AA) */
|
||||||
|
:focus-visible {
|
||||||
|
outline: 2px solid var(--ink-color);
|
||||||
|
outline-offset: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-brutalist:focus-visible,
|
||||||
|
.btn-ghost:focus-visible {
|
||||||
|
outline: 2px solid var(--bg-color);
|
||||||
|
outline-offset: 2px;
|
||||||
|
box-shadow: 0 0 0 4px var(--ink-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-nav-item:focus-visible {
|
||||||
|
outline: 2px solid #EAEAE5;
|
||||||
|
outline-offset: -2px;
|
||||||
|
}
|
||||||
@@ -12,7 +12,7 @@ import Modal from '@/components/ui/Modal';
|
|||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import type { AgentKey, Operation } from '@elydora/shared';
|
import type { AgentKey, Operation } from '@elydora/shared';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { ADAPTER_INTEGRATION_IDS } from '@/features/agent-registration/integrations';
|
import { HOOK_INTEGRATION_IDS } from '@/features/agent-registration/integrations';
|
||||||
|
|
||||||
export default function AgentDetailClient({ agentId }: { agentId: string }) {
|
export default function AgentDetailClient({ agentId }: { agentId: string }) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
@@ -37,7 +37,7 @@ export default function AgentDetailClient({ agentId }: { agentId: string }) {
|
|||||||
|
|
||||||
const agent = agentData?.agent;
|
const agent = agentData?.agent;
|
||||||
const supportsFreeze = agent
|
const supportsFreeze = agent
|
||||||
? ADAPTER_INTEGRATION_IDS.has(agent.integration_type)
|
? HOOK_INTEGRATION_IDS.has(agent.integration_type)
|
||||||
: false;
|
: false;
|
||||||
const keys = agentData?.keys ?? [];
|
const keys = agentData?.keys ?? [];
|
||||||
const operations = opsData?.operations ?? [];
|
const operations = opsData?.operations ?? [];
|
||||||
|
|||||||
@@ -93,7 +93,7 @@ export default function OperationDetailCard({ operation, receipt, payload }: Ope
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Payload — what the operation actually did */}
|
{/* Payload: what the operation did */}
|
||||||
{payload && (
|
{payload && (
|
||||||
<div className="border border-border bg-surface p-4 sm:p-6 relative">
|
<div className="border border-border bg-surface p-4 sm:p-6 relative">
|
||||||
<span className="crosshair ch-tl" />
|
<span className="crosshair ch-tl" />
|
||||||
|
|||||||
@@ -7,7 +7,9 @@ import type { IntegrationCatalogItem } from '@/features/agent-registration/integ
|
|||||||
import {
|
import {
|
||||||
buildInstallInstructions,
|
buildInstallInstructions,
|
||||||
SDK_LANGUAGES,
|
SDK_LANGUAGES,
|
||||||
|
SHELLS,
|
||||||
type SdkLanguage,
|
type SdkLanguage,
|
||||||
|
type Shell,
|
||||||
} from '@/features/agent-registration/install-instructions';
|
} from '@/features/agent-registration/install-instructions';
|
||||||
import CopyControl from './CopyControl';
|
import CopyControl from './CopyControl';
|
||||||
|
|
||||||
@@ -28,6 +30,7 @@ export default function ConnectStep({
|
|||||||
}: ConnectStepProps) {
|
}: ConnectStepProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const [language, setLanguage] = useState<SdkLanguage>('node');
|
const [language, setLanguage] = useState<SdkLanguage>('node');
|
||||||
|
const [shell, setShell] = useState<Shell>('posix');
|
||||||
const baseUrl = process.env.NEXT_PUBLIC_API_BASE_URL ?? 'http://localhost:8787';
|
const baseUrl = process.env.NEXT_PUBLIC_API_BASE_URL ?? 'http://localhost:8787';
|
||||||
const instructions = useMemo(
|
const instructions = useMemo(
|
||||||
() => buildInstallInstructions(language, {
|
() => buildInstallInstructions(language, {
|
||||||
@@ -38,9 +41,12 @@ export default function ConnectStep({
|
|||||||
orgId: credentials.orgId,
|
orgId: credentials.orgId,
|
||||||
},
|
},
|
||||||
baseUrl,
|
baseUrl,
|
||||||
}),
|
privateKey: credentials.privateKey,
|
||||||
[baseUrl, credentials.agentId, credentials.kid, credentials.orgId, integration, language],
|
token,
|
||||||
|
}, shell),
|
||||||
|
[baseUrl, credentials, integration, language, shell, token],
|
||||||
);
|
);
|
||||||
|
const PostInstallList = instructions.postInstall?.kind === 'alternatives' ? 'ul' : 'ol';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-5">
|
<div className="space-y-5">
|
||||||
@@ -66,10 +72,29 @@ export default function ConnectStep({
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{integration.mode === 'hooks' && (
|
||||||
|
<div className="flex border-b border-border" role="tablist" aria-label={t('agentRegistration.shell')}>
|
||||||
|
{SHELLS.map((item) => (
|
||||||
|
<button
|
||||||
|
key={item}
|
||||||
|
type="button"
|
||||||
|
role="tab"
|
||||||
|
aria-selected={shell === item}
|
||||||
|
onClick={() => setShell(item)}
|
||||||
|
className={`px-4 py-2 font-mono text-[10px] tracking-wider border-b-2 -mb-px ${
|
||||||
|
shell === item ? 'border-ink text-ink' : 'border-transparent text-ink-dim hover:text-ink'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{item === 'posix' ? 'sh' : 'PowerShell'}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<section aria-labelledby="setup-command-label">
|
<section aria-labelledby="setup-command-label">
|
||||||
<div className="flex items-center justify-between mb-2">
|
<div className="flex items-center justify-between mb-2">
|
||||||
<h3 id="setup-command-label" className="section-label">
|
<h3 id="setup-command-label" className="section-label">
|
||||||
{integration.mode === 'adapter' ? t('agentRegistration.install') : t('agentRegistration.configure')}
|
{integration.mode === 'hooks' ? t('agentRegistration.install') : t('agentRegistration.configure')}
|
||||||
</h3>
|
</h3>
|
||||||
<CopyControl
|
<CopyControl
|
||||||
value={instructions.setup}
|
value={instructions.setup}
|
||||||
@@ -138,14 +163,35 @@ export default function ConnectStep({
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{instructions.postInstall && (
|
{instructions.postInstall && (
|
||||||
<ol className="border-t border-border" aria-label={t('agentRegistration.postInstall')}>
|
<PostInstallList className="border-t border-border" aria-label={t('agentRegistration.postInstall')}>
|
||||||
{instructions.postInstall.map((step, index) => (
|
{instructions.postInstall.steps.map((step, index) => (
|
||||||
<li key={step} className="grid grid-cols-[2rem_1fr] gap-2 py-2 border-b border-border font-mono text-[11px] text-ink">
|
<li
|
||||||
<span className="text-ink-dim tabular-nums">{index + 1}</span>
|
key={`${step.key}:${step.command ?? ''}`}
|
||||||
<span>{step}</span>
|
className="group grid grid-cols-[2rem_minmax(0,1fr)_auto] items-start gap-2 border-b border-border py-2 font-mono text-[11px] text-ink"
|
||||||
|
>
|
||||||
|
<span className="text-ink-dim tabular-nums">
|
||||||
|
{step.variant ?? String(index + 1).padStart(2, '0')}
|
||||||
|
</span>
|
||||||
|
<span className="min-w-0">
|
||||||
|
<span className="block">
|
||||||
|
{t(`agentRegistration.postInstallSteps.${step.key}`, {
|
||||||
|
integration: integration.name,
|
||||||
|
})}
|
||||||
|
</span>
|
||||||
|
{step.command && (
|
||||||
|
<code className="mt-1 block break-all text-ink-dim">{step.command}</code>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
{step.command && (
|
||||||
|
<CopyControl
|
||||||
|
value={step.command}
|
||||||
|
ariaLabel={t('agentRegistration.copyPostInstall', { command: step.command })}
|
||||||
|
className="opacity-100 transition-opacity md:opacity-0 md:group-hover:opacity-100 md:group-focus-within:opacity-100"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</li>
|
</li>
|
||||||
))}
|
))}
|
||||||
</ol>
|
</PostInstallList>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="flex items-center justify-between pt-2">
|
<div className="flex items-center justify-between pt-2">
|
||||||
|
|||||||
@@ -35,6 +35,7 @@ function CredentialRow({ label, value }: { readonly label: string; readonly valu
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function CredentialsStep({
|
export default function CredentialsStep({
|
||||||
credentials,
|
credentials,
|
||||||
token,
|
token,
|
||||||
@@ -44,7 +45,7 @@ export default function CredentialsStep({
|
|||||||
onContinue,
|
onContinue,
|
||||||
}: CredentialsStepProps) {
|
}: CredentialsStepProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const [expiration, setExpiration] = useState<ExpirationId>('24hours');
|
const [expiration, setExpiration] = useState<ExpirationId>('neverExpire');
|
||||||
const [customDays, setCustomDays] = useState('');
|
const [customDays, setCustomDays] = useState('');
|
||||||
const [validationError, setValidationError] = useState<string | null>(null);
|
const [validationError, setValidationError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
|||||||
@@ -32,22 +32,30 @@ function IntegrationGroup({
|
|||||||
key={integration.id}
|
key={integration.id}
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => onSelect(integration)}
|
onClick={() => onSelect(integration)}
|
||||||
className="group flex items-center justify-between gap-3 px-3 py-3 text-left border-b border-border hover:bg-surface focus-visible:bg-surface focus-visible:outline-none"
|
className="group flex items-center justify-between gap-3 px-3 py-3 text-left border-b border-border hover:bg-surface focus-visible:bg-surface"
|
||||||
>
|
>
|
||||||
<span className="font-mono text-[12px] text-ink">{integration.name}</span>
|
<span className="font-mono text-[12px] text-ink">{integration.name}</span>
|
||||||
<span className="font-mono text-[10px] text-ink-dim opacity-0 group-hover:opacity-100 group-focus-visible:opacity-100 transition-opacity" aria-hidden="true">
|
<span className="flex items-center gap-2" aria-hidden="true">
|
||||||
|
{mode === 'hooks' && (
|
||||||
|
<span className="font-mono text-[9px] tracking-wider text-ink-dim border border-border px-1.5 py-0.5">
|
||||||
|
{t('agentRegistration.hookBadge')}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<span className="font-mono text-[10px] text-ink-dim opacity-0 group-hover:opacity-100 group-focus-visible:opacity-100 transition-opacity">
|
||||||
→
|
→
|
||||||
</span>
|
</span>
|
||||||
|
</span>
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function IntegrationStep({ onSelect }: IntegrationStepProps) {
|
export default function IntegrationStep({ onSelect }: IntegrationStepProps) {
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<IntegrationGroup mode="adapter" onSelect={onSelect} />
|
<IntegrationGroup mode="hooks" onSelect={onSelect} />
|
||||||
<IntegrationGroup mode="sdk" onSelect={onSelect} />
|
<IntegrationGroup mode="sdk" onSelect={onSelect} />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -45,7 +45,7 @@ export default function Modal({
|
|||||||
};
|
};
|
||||||
}, [isOpen]);
|
}, [isOpen]);
|
||||||
|
|
||||||
// Keydown listener — uses ref so it never changes identity
|
// Keydown listener uses a ref for stable identity
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isOpen) return;
|
if (!isOpen) return;
|
||||||
|
|
||||||
|
|||||||
@@ -135,7 +135,7 @@ export default function Sidebar({ isOpen, onClose }: SidebarProps) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{/* Backdrop overlay — mobile only */}
|
{/* Mobile backdrop overlay */}
|
||||||
{isOpen && (
|
{isOpen && (
|
||||||
<div
|
<div
|
||||||
className="fixed inset-0 bg-black/50 z-40 md:hidden"
|
className="fixed inset-0 bg-black/50 z-40 md:hidden"
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
import type { IntegrationCatalogItem } from './integrations';
|
import type { IntegrationCatalogItem, PostInstallPlan } from './integrations';
|
||||||
|
|
||||||
export const SDK_LANGUAGES = ['node', 'python', 'go'] as const;
|
export const SDK_LANGUAGES = ['node', 'python', 'go'] as const;
|
||||||
export type SdkLanguage = (typeof SDK_LANGUAGES)[number];
|
export type SdkLanguage = (typeof SDK_LANGUAGES)[number];
|
||||||
type SecretDelivery = 'hidden-prompts' | 'environment';
|
export const SHELLS = ['posix', 'powershell'] as const;
|
||||||
|
export type Shell = (typeof SHELLS)[number];
|
||||||
|
type SecretDelivery = 'embedded' | 'environment';
|
||||||
|
|
||||||
interface AgentInstallIdentity {
|
interface AgentInstallIdentity {
|
||||||
readonly agentId: string;
|
readonly agentId: string;
|
||||||
@@ -14,13 +16,15 @@ interface InstructionInput {
|
|||||||
readonly integration: IntegrationCatalogItem;
|
readonly integration: IntegrationCatalogItem;
|
||||||
readonly identity: AgentInstallIdentity;
|
readonly identity: AgentInstallIdentity;
|
||||||
readonly baseUrl: string;
|
readonly baseUrl: string;
|
||||||
|
readonly privateKey: string;
|
||||||
|
readonly token: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface InstallInstructions {
|
export interface InstallInstructions {
|
||||||
readonly setup: string;
|
readonly setup: string;
|
||||||
readonly usage?: string;
|
readonly usage?: string;
|
||||||
readonly verify?: string;
|
readonly verify?: string;
|
||||||
readonly postInstall?: readonly string[];
|
readonly postInstall?: PostInstallPlan;
|
||||||
readonly secretDelivery: SecretDelivery;
|
readonly secretDelivery: SecretDelivery;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -31,42 +35,97 @@ function shellQuoted(value: string, label: string): string {
|
|||||||
return `'${value}'`;
|
return `'${value}'`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function adapterCommand(
|
const INSTALLER: Record<SdkLanguage, string> = {
|
||||||
|
node: 'npx @elydora/sdk install',
|
||||||
|
python: 'elydora install',
|
||||||
|
go: 'elydora install',
|
||||||
|
};
|
||||||
|
const BOOTSTRAP: Partial<Record<SdkLanguage, string>> = {
|
||||||
|
python: 'python -m pip install elydora',
|
||||||
|
go: 'go install github.com/Elydora-Infrastructure/Elydora-Go-SDK/v2/cmd/elydora@latest',
|
||||||
|
};
|
||||||
|
|
||||||
|
interface CommandValues {
|
||||||
|
readonly agent: string;
|
||||||
|
readonly orgId: string;
|
||||||
|
readonly agentId: string;
|
||||||
|
readonly kid: string;
|
||||||
|
readonly baseUrl: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function credentialFileStem(agentId: string): string {
|
||||||
|
if (!/^[A-Za-z0-9._-]+$/u.test(agentId)) {
|
||||||
|
throw new Error('Agent ID cannot be used as a credential file name.');
|
||||||
|
}
|
||||||
|
return `.elydora-${agentId}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function installArguments(
|
||||||
language: SdkLanguage,
|
language: SdkLanguage,
|
||||||
{ integration, identity, baseUrl }: InstructionInput,
|
values: CommandValues,
|
||||||
|
keyFile: string,
|
||||||
|
tokenFile: string,
|
||||||
): string {
|
): string {
|
||||||
const values = {
|
if (language === 'go') {
|
||||||
|
return `--agent ${values.agent} --org-id ${values.orgId} --agent-id ${values.agentId} --kid ${values.kid} --base-url ${values.baseUrl} --private-key-file ${keyFile} --token-file ${tokenFile}`;
|
||||||
|
}
|
||||||
|
return `--agent ${values.agent} --org_id ${values.orgId} --agent_id ${values.agentId} --kid ${values.kid} --base_url ${values.baseUrl} --private_key_file ${keyFile} --token_file ${tokenFile}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function posixInstall(
|
||||||
|
language: SdkLanguage,
|
||||||
|
values: CommandValues,
|
||||||
|
stem: string,
|
||||||
|
privateKey: string,
|
||||||
|
token: string,
|
||||||
|
): string {
|
||||||
|
const template = `"$HOME/${stem}.XXXXXX"`;
|
||||||
|
const script = [
|
||||||
|
`k=$(mktemp ${template})`,
|
||||||
|
`trap 'rm -f "$k"' EXIT INT TERM`,
|
||||||
|
`t=$(mktemp ${template})`,
|
||||||
|
`trap 'rm -f "$k" "$t"' EXIT INT TERM`,
|
||||||
|
`printf '%s\\n' ${shellQuoted(privateKey, 'Private key')} > "$k"`,
|
||||||
|
`printf '%s\\n' ${shellQuoted(token, 'API token')} > "$t"`,
|
||||||
|
`${INSTALLER[language]} ${installArguments(language, values, '"$k"', '"$t"')}`,
|
||||||
|
].join(' && ');
|
||||||
|
return `printf '%s' '${script.replace(/'/gu, "'\\''")}' | sh`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function powershellInstall(
|
||||||
|
language: SdkLanguage,
|
||||||
|
values: CommandValues,
|
||||||
|
stem: string,
|
||||||
|
privateKey: string,
|
||||||
|
token: string,
|
||||||
|
): string {
|
||||||
|
const paths = `$k = Join-Path $HOME '${stem}.key'; $t = Join-Path $HOME '${stem}.token'`;
|
||||||
|
const create = 'New-Item -ItemType File -Force -Path $k, $t | Out-Null';
|
||||||
|
const protect = 'if ($IsLinux -or $IsMacOS) { chmod 600 $k $t; if ($LASTEXITCODE -ne 0) { throw "chmod failed" } } else { $u = [System.Security.Principal.WindowsIdentity]::GetCurrent().User.Value; foreach ($f in $k, $t) { icacls $f /inheritance:r /grant:r "*$($u):F" | Out-Null; if ($LASTEXITCODE -ne 0) { throw "icacls failed for $f" } } }';
|
||||||
|
const store = `Set-Content -LiteralPath $k -Value ${shellQuoted(privateKey, 'Private key')}; Set-Content -LiteralPath $t -Value ${shellQuoted(token, 'API token')}`;
|
||||||
|
const install = `${INSTALLER[language]} ${installArguments(language, values, '$k', '$t')}`;
|
||||||
|
const status = 'if ($LASTEXITCODE -ne 0) { throw "elydora install exited with code $LASTEXITCODE" }';
|
||||||
|
return `${paths}; try { ${create}; ${protect}; ${store}; ${install}; ${status} } finally { Remove-Item -LiteralPath $k, $t -Force -ErrorAction SilentlyContinue }`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function hookInstallerCommand(
|
||||||
|
language: SdkLanguage,
|
||||||
|
{ integration, identity, baseUrl, privateKey, token }: InstructionInput,
|
||||||
|
shell: Shell,
|
||||||
|
): string {
|
||||||
|
const values: CommandValues = {
|
||||||
agent: integration.id,
|
agent: integration.id,
|
||||||
orgId: shellQuoted(identity.orgId, 'Organization ID'),
|
orgId: shellQuoted(identity.orgId, 'Organization ID'),
|
||||||
agentId: shellQuoted(identity.agentId, 'Agent ID'),
|
agentId: shellQuoted(identity.agentId, 'Agent ID'),
|
||||||
kid: shellQuoted(identity.kid, 'Key ID'),
|
kid: shellQuoted(identity.kid, 'Key ID'),
|
||||||
baseUrl: shellQuoted(baseUrl, 'API base URL'),
|
baseUrl: shellQuoted(baseUrl, 'API base URL'),
|
||||||
};
|
};
|
||||||
|
const stem = credentialFileStem(identity.agentId);
|
||||||
if (language === 'go') {
|
const command = shell === 'powershell'
|
||||||
return [
|
? powershellInstall(language, values, stem, privateKey, token)
|
||||||
'go install github.com/Elydora-Infrastructure/Elydora-Go-SDK/cmd/elydora@latest',
|
: posixInstall(language, values, stem, privateKey, token);
|
||||||
`elydora install --agent ${values.agent} --org-id ${values.orgId} --agent-id ${values.agentId} --kid ${values.kid} --base-url ${values.baseUrl}`,
|
const bootstrap = BOOTSTRAP[language];
|
||||||
].join('\n');
|
return bootstrap ? `${bootstrap}\n${command}` : command;
|
||||||
}
|
|
||||||
|
|
||||||
const executable = language === 'node'
|
|
||||||
? 'npx @elydora/sdk install'
|
|
||||||
: 'python -m pip install elydora\nelydora install';
|
|
||||||
return `${executable} --agent ${values.agent} --org_id ${values.orgId} --agent_id ${values.agentId} --kid ${values.kid} --base_url ${values.baseUrl}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function postInstallSteps(integration: IntegrationCatalogItem): readonly string[] | undefined {
|
|
||||||
if (integration.postInstall === 'review-hooks') {
|
|
||||||
return ['Open the agent and run /hooks to review the active Elydora hooks.'];
|
|
||||||
}
|
|
||||||
if (integration.postInstall === 'start-kiro') {
|
|
||||||
return [
|
|
||||||
'Kiro CLI v2: kiro-cli --agent elydora-audit',
|
|
||||||
'Kiro CLI v3: kiro-cli --v3',
|
|
||||||
];
|
|
||||||
}
|
|
||||||
return undefined;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function sdkSetup(language: SdkLanguage, input: InstructionInput): string {
|
function sdkSetup(language: SdkLanguage, input: InstructionInput): string {
|
||||||
@@ -95,7 +154,7 @@ client.set_kid(${JSON.stringify(identity.kid)})`;
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (language === 'go') {
|
if (language === 'go') {
|
||||||
return `go get github.com/Elydora-Infrastructure/Elydora-Go-SDK
|
return `go get github.com/Elydora-Infrastructure/Elydora-Go-SDK/v2
|
||||||
|
|
||||||
requireEnv := func(name string) string {
|
requireEnv := func(name string) string {
|
||||||
value, ok := os.LookupEnv(name)
|
value, ok := os.LookupEnv(name)
|
||||||
@@ -176,13 +235,14 @@ await client.submitOperation(operation);`;
|
|||||||
export function buildInstallInstructions(
|
export function buildInstallInstructions(
|
||||||
language: SdkLanguage,
|
language: SdkLanguage,
|
||||||
input: InstructionInput,
|
input: InstructionInput,
|
||||||
|
shell: Shell = 'posix',
|
||||||
): InstallInstructions {
|
): InstallInstructions {
|
||||||
if (input.integration.mode === 'adapter') {
|
if (input.integration.mode === 'hooks') {
|
||||||
return {
|
return {
|
||||||
setup: adapterCommand(language, input),
|
setup: hookInstallerCommand(language, input, shell),
|
||||||
verify: language === 'node' ? 'npx @elydora/sdk status' : 'elydora status',
|
verify: language === 'node' ? 'npx @elydora/sdk status' : 'elydora status',
|
||||||
postInstall: postInstallSteps(input.integration),
|
postInstall: input.integration.postInstall,
|
||||||
secretDelivery: 'hidden-prompts',
|
secretDelivery: 'embedded',
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -5,35 +5,169 @@ import {
|
|||||||
|
|
||||||
export { INTEGRATION_TYPES };
|
export { INTEGRATION_TYPES };
|
||||||
|
|
||||||
export type IntegrationMode = 'adapter' | 'sdk';
|
export type IntegrationMode = 'hooks' | 'sdk';
|
||||||
export type PostInstallAction = 'review-hooks' | 'start-kiro';
|
export type PostInstallStepKey =
|
||||||
|
| 'inspectEnvironment'
|
||||||
|
| 'inspectCursorHooks'
|
||||||
|
| 'inspectGrokConfig'
|
||||||
|
| 'inspectKiroIdeHooks'
|
||||||
|
| 'launchKiroV2'
|
||||||
|
| 'launchKiroV3'
|
||||||
|
| 'listHooks'
|
||||||
|
| 'openHooks'
|
||||||
|
| 'reviewAndTrustHooks'
|
||||||
|
| 'restartSession'
|
||||||
|
| 'startSession'
|
||||||
|
| 'startTask';
|
||||||
|
|
||||||
|
export interface PostInstallStep {
|
||||||
|
readonly key: PostInstallStepKey;
|
||||||
|
readonly command?: string;
|
||||||
|
readonly variant?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PostInstallPlan {
|
||||||
|
readonly kind: 'sequence' | 'alternatives';
|
||||||
|
readonly steps: readonly PostInstallStep[];
|
||||||
|
}
|
||||||
|
|
||||||
export interface IntegrationCatalogItem {
|
export interface IntegrationCatalogItem {
|
||||||
readonly id: IntegrationType;
|
readonly id: IntegrationType;
|
||||||
readonly name: string;
|
readonly name: string;
|
||||||
readonly mode: IntegrationMode;
|
readonly mode: IntegrationMode;
|
||||||
readonly postInstall?: PostInstallAction;
|
readonly postInstall?: PostInstallPlan;
|
||||||
}
|
}
|
||||||
|
|
||||||
const INTEGRATION_METADATA: Record<
|
const INTEGRATION_METADATA: Record<
|
||||||
IntegrationType,
|
IntegrationType,
|
||||||
Omit<IntegrationCatalogItem, 'id'>
|
Omit<IntegrationCatalogItem, 'id'>
|
||||||
> = {
|
> = {
|
||||||
augment: { name: 'Augment Code', mode: 'adapter' },
|
augment: {
|
||||||
claudecode: { name: 'Claude Code', mode: 'adapter' },
|
name: 'Augment Code',
|
||||||
cline: { name: 'Cline', mode: 'adapter' },
|
mode: 'hooks',
|
||||||
codex: { name: 'OpenAI Codex', mode: 'adapter', postInstall: 'review-hooks' },
|
postInstall: {
|
||||||
copilot: { name: 'GitHub Copilot CLI', mode: 'adapter' },
|
kind: 'sequence',
|
||||||
cursor: { name: 'Cursor', mode: 'adapter' },
|
steps: [{ key: 'startSession', command: 'auggie' }],
|
||||||
droid: { name: 'Factory Droid', mode: 'adapter', postInstall: 'review-hooks' },
|
},
|
||||||
gemini: { name: 'Gemini CLI', mode: 'adapter' },
|
},
|
||||||
grok: { name: 'Grok Build', mode: 'adapter' },
|
claudecode: {
|
||||||
kimi: { name: 'Kimi CLI', mode: 'adapter', postInstall: 'review-hooks' },
|
name: 'Claude Code',
|
||||||
kirocli: { name: 'Kiro CLI', mode: 'adapter', postInstall: 'start-kiro' },
|
mode: 'hooks',
|
||||||
kiroide: { name: 'Kiro IDE', mode: 'adapter' },
|
postInstall: {
|
||||||
letta: { name: 'Letta Code', mode: 'adapter' },
|
kind: 'sequence',
|
||||||
opencode: { name: 'OpenCode', mode: 'adapter' },
|
steps: [{ key: 'openHooks', command: '/hooks' }],
|
||||||
qwen: { name: 'Qwen Code', mode: 'adapter', postInstall: 'review-hooks' },
|
},
|
||||||
|
},
|
||||||
|
cline: {
|
||||||
|
name: 'Cline',
|
||||||
|
mode: 'hooks',
|
||||||
|
postInstall: {
|
||||||
|
kind: 'sequence',
|
||||||
|
steps: [{ key: 'startTask' }],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
codex: {
|
||||||
|
name: 'Codex CLI',
|
||||||
|
mode: 'hooks',
|
||||||
|
postInstall: {
|
||||||
|
kind: 'sequence',
|
||||||
|
steps: [{ key: 'reviewAndTrustHooks', command: '/hooks' }],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
copilot: {
|
||||||
|
name: 'GitHub Copilot CLI',
|
||||||
|
mode: 'hooks',
|
||||||
|
postInstall: {
|
||||||
|
kind: 'sequence',
|
||||||
|
steps: [
|
||||||
|
{ key: 'restartSession', command: '/restart' },
|
||||||
|
{ key: 'inspectEnvironment', command: '/env' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
cursor: {
|
||||||
|
name: 'Cursor CLI',
|
||||||
|
mode: 'hooks',
|
||||||
|
postInstall: {
|
||||||
|
kind: 'sequence',
|
||||||
|
steps: [{ key: 'inspectCursorHooks' }],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
droid: {
|
||||||
|
name: 'Factory Droid',
|
||||||
|
mode: 'hooks',
|
||||||
|
postInstall: {
|
||||||
|
kind: 'sequence',
|
||||||
|
steps: [{ key: 'openHooks', command: '/hooks' }],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
gemini: {
|
||||||
|
name: 'Gemini CLI',
|
||||||
|
mode: 'hooks',
|
||||||
|
postInstall: {
|
||||||
|
kind: 'sequence',
|
||||||
|
steps: [{ key: 'listHooks', command: '/hooks list' }],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
grok: {
|
||||||
|
name: 'Grok Build',
|
||||||
|
mode: 'hooks',
|
||||||
|
postInstall: {
|
||||||
|
kind: 'sequence',
|
||||||
|
steps: [{ key: 'inspectGrokConfig', command: 'grok inspect --json' }],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
kimi: {
|
||||||
|
name: 'Kimi Code',
|
||||||
|
mode: 'hooks',
|
||||||
|
postInstall: {
|
||||||
|
kind: 'sequence',
|
||||||
|
steps: [{ key: 'startSession', command: 'kimi' }],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
kirocli: {
|
||||||
|
name: 'Kiro CLI',
|
||||||
|
mode: 'hooks',
|
||||||
|
postInstall: {
|
||||||
|
kind: 'alternatives',
|
||||||
|
steps: [
|
||||||
|
{ key: 'launchKiroV2', command: 'kiro-cli --agent elydora-audit', variant: 'V2' },
|
||||||
|
{ key: 'launchKiroV3', command: 'kiro-cli --v3', variant: 'V3' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
kiroide: {
|
||||||
|
name: 'Kiro IDE',
|
||||||
|
mode: 'hooks',
|
||||||
|
postInstall: {
|
||||||
|
kind: 'sequence',
|
||||||
|
steps: [{ key: 'inspectKiroIdeHooks' }],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
letta: {
|
||||||
|
name: 'Letta Code',
|
||||||
|
mode: 'hooks',
|
||||||
|
postInstall: {
|
||||||
|
kind: 'sequence',
|
||||||
|
steps: [{ key: 'openHooks', command: '/hooks' }],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
opencode: {
|
||||||
|
name: 'OpenCode',
|
||||||
|
mode: 'hooks',
|
||||||
|
postInstall: {
|
||||||
|
kind: 'sequence',
|
||||||
|
steps: [{ key: 'startSession', command: 'opencode' }],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
qwen: {
|
||||||
|
name: 'Qwen Code',
|
||||||
|
mode: 'hooks',
|
||||||
|
postInstall: {
|
||||||
|
kind: 'sequence',
|
||||||
|
steps: [{ key: 'listHooks', command: '/hooks list' }],
|
||||||
|
},
|
||||||
|
},
|
||||||
enterprise: { name: 'Enterprise Agent', mode: 'sdk' },
|
enterprise: { name: 'Enterprise Agent', mode: 'sdk' },
|
||||||
gui: { name: 'GUI Agent', mode: 'sdk' },
|
gui: { name: 'GUI Agent', mode: 'sdk' },
|
||||||
sdk: { name: 'SDK', mode: 'sdk' },
|
sdk: { name: 'SDK', mode: 'sdk' },
|
||||||
@@ -43,6 +177,6 @@ const INTEGRATION_METADATA: Record<
|
|||||||
export const INTEGRATION_CATALOG: readonly IntegrationCatalogItem[] =
|
export const INTEGRATION_CATALOG: readonly IntegrationCatalogItem[] =
|
||||||
INTEGRATION_TYPES.map((id) => ({ id, ...INTEGRATION_METADATA[id] }));
|
INTEGRATION_TYPES.map((id) => ({ id, ...INTEGRATION_METADATA[id] }));
|
||||||
|
|
||||||
export const ADAPTER_INTEGRATION_IDS: ReadonlySet<IntegrationType> = new Set(
|
export const HOOK_INTEGRATION_IDS: ReadonlySet<IntegrationType> = new Set(
|
||||||
INTEGRATION_CATALOG.filter(({ mode }) => mode === 'adapter').map(({ id }) => id),
|
INTEGRATION_CATALOG.filter(({ mode }) => mode === 'hooks').map(({ id }) => id),
|
||||||
);
|
);
|
||||||
@@ -330,13 +330,15 @@
|
|||||||
"connect": "Connect"
|
"connect": "Connect"
|
||||||
},
|
},
|
||||||
"integrationGroups": {
|
"integrationGroups": {
|
||||||
"adapter": "CLI adapters",
|
"hooks": "Native hooks",
|
||||||
"sdk": "Custom SDK"
|
"sdk": "Custom SDK"
|
||||||
},
|
},
|
||||||
|
"hookBadge": "HOOKS",
|
||||||
"integration": "Integration",
|
"integration": "Integration",
|
||||||
"copySetup": "Copy setup command",
|
"copySetup": "Copy setup command",
|
||||||
"copyUsage": "Copy operation example",
|
"copyUsage": "Copy operation example",
|
||||||
"copyVerify": "Copy verification command",
|
"copyVerify": "Copy verification command",
|
||||||
|
"copyPostInstall": "Copy activation command: {{command}}",
|
||||||
"copyPrivateKey": "Copy private key",
|
"copyPrivateKey": "Copy private key",
|
||||||
"copyApiToken": "Copy API token",
|
"copyApiToken": "Copy API token",
|
||||||
"copyFailed": "Copy failed.",
|
"copyFailed": "Copy failed.",
|
||||||
@@ -346,9 +348,24 @@
|
|||||||
"record": "Record an operation",
|
"record": "Record an operation",
|
||||||
"verify": "Verify",
|
"verify": "Verify",
|
||||||
"postInstall": "Post-install steps",
|
"postInstall": "Post-install steps",
|
||||||
|
"postInstallSteps": {
|
||||||
|
"inspectCursorHooks": "Open Customize → Hooks and confirm Elydora in the Hooks output channel.",
|
||||||
|
"inspectEnvironment": "Confirm the Elydora hooks in the loaded environment.",
|
||||||
|
"inspectGrokConfig": "Confirm the Elydora PreToolUse, PostToolUse, and PostToolUseFailure hooks.",
|
||||||
|
"inspectKiroIdeHooks": "Open Agent Hooks in the Kiro panel and confirm Elydora Audit is enabled.",
|
||||||
|
"launchKiroV2": "Launch the Elydora audit agent in Kiro CLI v2.",
|
||||||
|
"launchKiroV3": "Start Kiro CLI v3 with the global Elydora hooks.",
|
||||||
|
"listHooks": "List the loaded Elydora hooks.",
|
||||||
|
"openHooks": "Open the hook manager and review the Elydora hooks.",
|
||||||
|
"reviewAndTrustHooks": "Review and trust both Elydora command hooks.",
|
||||||
|
"restartSession": "Restart the current {{integration}} session.",
|
||||||
|
"startSession": "Start a new {{integration}} session.",
|
||||||
|
"startTask": "Start a new {{integration}} task."
|
||||||
|
},
|
||||||
"sdkLanguage": "SDK language",
|
"sdkLanguage": "SDK language",
|
||||||
|
"shell": "Shell",
|
||||||
"secretDelivery": {
|
"secretDelivery": {
|
||||||
"hidden-prompts": "The CLI requests both values with terminal echo disabled.",
|
"embedded": "The command stores both credentials in owner-only files, installs the hook, then deletes them.",
|
||||||
"environment": "Set ELYDORA_PRIVATE_KEY and ELYDORA_API_TOKEN in the runtime environment."
|
"environment": "Set ELYDORA_PRIVATE_KEY and ELYDORA_API_TOKEN in the runtime environment."
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -330,13 +330,15 @@
|
|||||||
"connect": "连接"
|
"connect": "连接"
|
||||||
},
|
},
|
||||||
"integrationGroups": {
|
"integrationGroups": {
|
||||||
"adapter": "CLI 适配器",
|
"hooks": "原生 Hooks",
|
||||||
"sdk": "自定义 SDK"
|
"sdk": "自定义 SDK"
|
||||||
},
|
},
|
||||||
|
"hookBadge": "HOOKS",
|
||||||
"integration": "集成",
|
"integration": "集成",
|
||||||
"copySetup": "复制安装命令",
|
"copySetup": "复制安装命令",
|
||||||
"copyUsage": "复制操作示例",
|
"copyUsage": "复制操作示例",
|
||||||
"copyVerify": "复制验证命令",
|
"copyVerify": "复制验证命令",
|
||||||
|
"copyPostInstall": "复制激活命令:{{command}}",
|
||||||
"copyPrivateKey": "复制私钥",
|
"copyPrivateKey": "复制私钥",
|
||||||
"copyApiToken": "复制 API 令牌",
|
"copyApiToken": "复制 API 令牌",
|
||||||
"copyFailed": "复制失败。",
|
"copyFailed": "复制失败。",
|
||||||
@@ -346,9 +348,24 @@
|
|||||||
"record": "记录操作",
|
"record": "记录操作",
|
||||||
"verify": "验证",
|
"verify": "验证",
|
||||||
"postInstall": "安装后步骤",
|
"postInstall": "安装后步骤",
|
||||||
|
"postInstallSteps": {
|
||||||
|
"inspectCursorHooks": "打开 Customize → Hooks,在 Hooks 输出通道中确认 Elydora。",
|
||||||
|
"inspectEnvironment": "确认已加载环境包含 Elydora hooks。",
|
||||||
|
"inspectGrokConfig": "确认 Elydora PreToolUse、PostToolUse 与 PostToolUseFailure hooks。",
|
||||||
|
"inspectKiroIdeHooks": "打开 Kiro 面板中的 Agent Hooks,确认 Elydora Audit 已启用。",
|
||||||
|
"launchKiroV2": "在 Kiro CLI v2 中启动 Elydora audit agent。",
|
||||||
|
"launchKiroV3": "启动 Kiro CLI v3 并加载全局 Elydora hooks。",
|
||||||
|
"listHooks": "列出已加载的 Elydora hooks。",
|
||||||
|
"openHooks": "打开 hook 管理器并检查 Elydora hooks。",
|
||||||
|
"reviewAndTrustHooks": "检查并信任两个 Elydora command hooks。",
|
||||||
|
"restartSession": "重启当前 {{integration}} 会话。",
|
||||||
|
"startSession": "启动新的 {{integration}} 会话。",
|
||||||
|
"startTask": "启动新的 {{integration}} 任务。"
|
||||||
|
},
|
||||||
"sdkLanguage": "SDK 语言",
|
"sdkLanguage": "SDK 语言",
|
||||||
|
"shell": "终端",
|
||||||
"secretDelivery": {
|
"secretDelivery": {
|
||||||
"hidden-prompts": "CLI 会在关闭终端回显后请求这两项凭据。",
|
"embedded": "命令会把两项凭据写入仅所有者可读的文件,安装 hook 后立即删除。",
|
||||||
"environment": "请在运行环境中设置 ELYDORA_PRIVATE_KEY 与 ELYDORA_API_TOKEN。"
|
"environment": "请在运行环境中设置 ELYDORA_PRIVATE_KEY 与 ELYDORA_API_TOKEN。"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -0,0 +1,24 @@
|
|||||||
|
-- Opaque API tokens stored as SHA-256 hashes.
|
||||||
|
|
||||||
|
BEGIN;
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS api_tokens (
|
||||||
|
token_id TEXT NOT NULL PRIMARY KEY,
|
||||||
|
user_id TEXT NOT NULL REFERENCES users (user_id),
|
||||||
|
org_id TEXT NOT NULL REFERENCES organizations (org_id),
|
||||||
|
token_hash TEXT NOT NULL UNIQUE,
|
||||||
|
issued_at BIGINT NOT NULL,
|
||||||
|
expires_at BIGINT
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_api_tokens_user_id ON api_tokens (user_id);
|
||||||
|
|
||||||
|
INSERT INTO schema_versions (version, applied_at, description)
|
||||||
|
VALUES (
|
||||||
|
3,
|
||||||
|
EXTRACT(EPOCH FROM NOW())::BIGINT * 1000,
|
||||||
|
'Opaque API tokens'
|
||||||
|
)
|
||||||
|
ON CONFLICT (version) DO NOTHING;
|
||||||
|
|
||||||
|
COMMIT;
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
-- Restore the constraints and column widths that the Compose bootstrap applied.
|
||||||
|
|
||||||
|
BEGIN;
|
||||||
|
|
||||||
|
ALTER TABLE schema_versions ALTER COLUMN version TYPE BIGINT;
|
||||||
|
ALTER TABLE operations ALTER COLUMN seq_no TYPE BIGINT;
|
||||||
|
ALTER TABLE operations ALTER COLUMN ttl_ms TYPE BIGINT;
|
||||||
|
ALTER TABLE epochs ALTER COLUMN leaf_count TYPE BIGINT;
|
||||||
|
|
||||||
|
ALTER TABLE agents DROP CONSTRAINT IF EXISTS agents_org_id_fkey;
|
||||||
|
ALTER TABLE agents
|
||||||
|
ADD CONSTRAINT agents_org_id_fkey FOREIGN KEY (org_id) REFERENCES organizations (org_id);
|
||||||
|
|
||||||
|
ALTER TABLE users DROP CONSTRAINT IF EXISTS users_role_check;
|
||||||
|
ALTER TABLE users
|
||||||
|
ADD CONSTRAINT users_role_check
|
||||||
|
CHECK (role IN ('org_owner', 'security_admin', 'compliance_auditor', 'readonly_investigator', 'integration_engineer'));
|
||||||
|
|
||||||
|
ALTER TABLE users DROP CONSTRAINT IF EXISTS users_status_check;
|
||||||
|
ALTER TABLE users
|
||||||
|
ADD CONSTRAINT users_status_check CHECK (status IN ('active', 'suspended'));
|
||||||
|
|
||||||
|
ALTER TABLE agents DROP CONSTRAINT IF EXISTS agents_status_check;
|
||||||
|
ALTER TABLE agents
|
||||||
|
ADD CONSTRAINT agents_status_check CHECK (status IN ('active', 'frozen', 'revoked'));
|
||||||
|
|
||||||
|
ALTER TABLE agent_keys DROP CONSTRAINT IF EXISTS agent_keys_algorithm_check;
|
||||||
|
ALTER TABLE agent_keys
|
||||||
|
ADD CONSTRAINT agent_keys_algorithm_check CHECK (algorithm = 'ed25519');
|
||||||
|
|
||||||
|
ALTER TABLE agent_keys DROP CONSTRAINT IF EXISTS agent_keys_status_check;
|
||||||
|
ALTER TABLE agent_keys
|
||||||
|
ADD CONSTRAINT agent_keys_status_check CHECK (status IN ('active', 'retired', 'revoked'));
|
||||||
|
|
||||||
|
ALTER TABLE exports DROP CONSTRAINT IF EXISTS exports_status_check;
|
||||||
|
ALTER TABLE exports
|
||||||
|
ADD CONSTRAINT exports_status_check CHECK (status IN ('queued', 'running', 'done', 'failed'));
|
||||||
|
|
||||||
|
INSERT INTO schema_versions (version, applied_at, description)
|
||||||
|
VALUES (
|
||||||
|
4,
|
||||||
|
EXTRACT(EPOCH FROM NOW())::BIGINT * 1000,
|
||||||
|
'Restore status constraints, agent org foreign key, and 64-bit counters'
|
||||||
|
)
|
||||||
|
ON CONFLICT (version) DO NOTHING;
|
||||||
|
|
||||||
|
COMMIT;
|
||||||
@@ -1,10 +1,4 @@
|
|||||||
/**
|
/** BullMQ queue adapter implementing the MessageQueue interface. */
|
||||||
* 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 { Queue as BullQueue } from 'bullmq';
|
||||||
import type { ConnectionOptions } from 'bullmq';
|
import type { ConnectionOptions } from 'bullmq';
|
||||||
|
|||||||
@@ -1,24 +1,12 @@
|
|||||||
/**
|
/** Adapter interfaces for ElydoraOpenSource infrastructure services. */
|
||||||
* 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)
|
// Database (replaces D1Database)
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
/**
|
/** A prepared (and optionally bound) SQL statement. */
|
||||||
* A prepared (and optionally bound) SQL statement.
|
|
||||||
* Mirrors D1PreparedStatement's interface exactly.
|
|
||||||
*/
|
|
||||||
export interface PreparedStatement {
|
export interface PreparedStatement {
|
||||||
/**
|
/** Bind positional parameters (? placeholders) to the statement. */
|
||||||
* Bind positional parameters (? placeholders) to the statement.
|
|
||||||
* Returns a new statement with the values set.
|
|
||||||
*/
|
|
||||||
bind(...values: unknown[]): PreparedStatement;
|
bind(...values: unknown[]): PreparedStatement;
|
||||||
|
|
||||||
/** Execute and return the first row, or null if no rows match. */
|
/** Execute and return the first row, or null if no rows match. */
|
||||||
@@ -31,18 +19,12 @@ export interface PreparedStatement {
|
|||||||
run(): Promise<{ success: boolean }>;
|
run(): Promise<{ success: boolean }>;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/** Relational database adapter. */
|
||||||
* Relational database adapter.
|
|
||||||
* Mirrors the subset of D1Database methods used by Elydora services.
|
|
||||||
*/
|
|
||||||
export interface Database {
|
export interface Database {
|
||||||
/** Prepare a SQL statement for execution. */
|
/** Prepare a SQL statement for execution. */
|
||||||
prepare(sql: string): PreparedStatement;
|
prepare(sql: string): PreparedStatement;
|
||||||
|
|
||||||
/**
|
/** Execute multiple prepared statements atomically (within a transaction). */
|
||||||
* Execute multiple prepared statements atomically (within a transaction).
|
|
||||||
* Mirrors D1Database.batch().
|
|
||||||
*/
|
|
||||||
batch(statements: PreparedStatement[]): Promise<{ results: unknown[]; success: boolean }[]>;
|
batch(statements: PreparedStatement[]): Promise<{ results: unknown[]; success: boolean }[]>;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -73,10 +55,7 @@ export interface ObjectStoreHead {
|
|||||||
readonly httpMetadata?: { readonly contentType?: string };
|
readonly httpMetadata?: { readonly contentType?: string };
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/** Object/blob storage adapter. */
|
||||||
* Object/blob storage adapter.
|
|
||||||
* Mirrors the subset of R2Bucket methods used by Elydora services.
|
|
||||||
*/
|
|
||||||
export interface ObjectStore {
|
export interface ObjectStore {
|
||||||
/** Upload an object to the store. */
|
/** Upload an object to the store. */
|
||||||
put(
|
put(
|
||||||
@@ -96,10 +75,7 @@ export interface ObjectStore {
|
|||||||
// Cache (replaces KVNamespace)
|
// Cache (replaces KVNamespace)
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
/**
|
/** Key/value cache adapter. */
|
||||||
* Key/value cache adapter.
|
|
||||||
* Mirrors the subset of KVNamespace methods used by Elydora services.
|
|
||||||
*/
|
|
||||||
export interface Cache {
|
export interface Cache {
|
||||||
/** Retrieve a cached value, or null if not found / expired. */
|
/** Retrieve a cached value, or null if not found / expired. */
|
||||||
get(key: string): Promise<string | null>;
|
get(key: string): Promise<string | null>;
|
||||||
@@ -112,10 +88,7 @@ export interface Cache {
|
|||||||
// Message queue (replaces Queue)
|
// Message queue (replaces Queue)
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
/**
|
/** Message queue adapter. */
|
||||||
* Message queue adapter.
|
|
||||||
* Mirrors the subset of Cloudflare Queue methods used by Elydora services.
|
|
||||||
*/
|
|
||||||
export interface MessageQueue {
|
export interface MessageQueue {
|
||||||
/** Enqueue a message under the caller's durable message ID. */
|
/** Enqueue a message under the caller's durable message ID. */
|
||||||
send(messageId: string, body: unknown): Promise<void>;
|
send(messageId: string, body: unknown): Promise<void>;
|
||||||
|
|||||||
@@ -1,10 +1,4 @@
|
|||||||
/**
|
/** MinIO / S3-compatible object storage adapter implementing the ObjectStore interface. */
|
||||||
* 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 {
|
import {
|
||||||
S3Client,
|
S3Client,
|
||||||
@@ -64,7 +58,7 @@ export class MinioAdapter implements ObjectStore {
|
|||||||
} else if (body instanceof Uint8Array) {
|
} else if (body instanceof Uint8Array) {
|
||||||
bodyData = body;
|
bodyData = body;
|
||||||
} else {
|
} else {
|
||||||
// Web ReadableStream — collect into buffer
|
// Web ReadableStream: collect into buffer
|
||||||
const chunks: Uint8Array[] = [];
|
const chunks: Uint8Array[] = [];
|
||||||
const reader = body.getReader();
|
const reader = body.getReader();
|
||||||
for (;;) {
|
for (;;) {
|
||||||
|
|||||||
@@ -1,13 +1,4 @@
|
|||||||
/**
|
/** PostgreSQL adapter implementing the Database interface. */
|
||||||
* 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 pg from 'pg';
|
||||||
import type { Database, PreparedStatement } from './interfaces.js';
|
import type { Database, PreparedStatement } from './interfaces.js';
|
||||||
@@ -25,10 +16,7 @@ types.setTypeParser(20, (val: string) => Number(val));
|
|||||||
// Placeholder conversion
|
// Placeholder conversion
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
/**
|
/** Convert SQLite `?` positional placeholders to PostgreSQL `$N` style. */
|
||||||
* 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 {
|
function convertPlaceholders(sql: string): string {
|
||||||
let index = 0;
|
let index = 0;
|
||||||
return sql.replace(/\?/g, () => `$${++index}`);
|
return sql.replace(/\?/g, () => `$${++index}`);
|
||||||
@@ -38,10 +26,7 @@ function convertPlaceholders(sql: string): string {
|
|||||||
// Statement implementation
|
// Statement implementation
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
/**
|
/** Internal PostgreSQL statement that implements PreparedStatement. */
|
||||||
* Internal PostgreSQL statement that implements PreparedStatement.
|
|
||||||
* Stores the SQL and bound values; executes lazily on first/all/run/executeWith.
|
|
||||||
*/
|
|
||||||
class PostgresStatement implements PreparedStatement {
|
class PostgresStatement implements PreparedStatement {
|
||||||
constructor(
|
constructor(
|
||||||
private readonly pool: Pool,
|
private readonly pool: Pool,
|
||||||
|
|||||||
@@ -1,9 +1,4 @@
|
|||||||
/**
|
/** Redis cache adapter implementing the Cache interface. */
|
||||||
* 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 { Redis } from 'ioredis';
|
||||||
import type { Cache } from './interfaces.js';
|
import type { Cache } from './interfaces.js';
|
||||||
|
|||||||
@@ -1,15 +1,4 @@
|
|||||||
/**
|
/** Internationalization (i18n) translation map. */
|
||||||
* 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';
|
export type Lang = 'en' | 'zh';
|
||||||
|
|
||||||
@@ -29,6 +18,14 @@ const messages: Record<string, Record<Lang, string>> = {
|
|||||||
en: 'The signing key has been revoked.',
|
en: 'The signing key has been revoked.',
|
||||||
zh: '\u7b7e\u540d\u5bc6\u94a5\u5df2\u88ab\u64a4\u9500\u3002',
|
zh: '\u7b7e\u540d\u5bc6\u94a5\u5df2\u88ab\u64a4\u9500\u3002',
|
||||||
},
|
},
|
||||||
|
'error.keyRetired': {
|
||||||
|
en: 'The signing key has been retired.',
|
||||||
|
zh: '\u7b7e\u540d\u5bc6\u94a5\u5df2\u9000\u5f79\u3002',
|
||||||
|
},
|
||||||
|
'error.agentRevoked': {
|
||||||
|
en: 'The agent has been revoked.',
|
||||||
|
zh: '\u8be5\u4ee3\u7406\u5df2\u88ab\u540a\u9500\u3002',
|
||||||
|
},
|
||||||
'error.agentFrozen': {
|
'error.agentFrozen': {
|
||||||
en: 'The agent is frozen and cannot submit operations.',
|
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',
|
zh: '\u8be5\u4ee3\u7406\u5df2\u88ab\u51bb\u7ed3\uff0c\u65e0\u6cd5\u63d0\u4ea4\u64cd\u4f5c\u3002',
|
||||||
@@ -90,8 +87,8 @@ const messages: Record<string, Record<Lang, string>> = {
|
|||||||
zh: 'Bearer \u4ee4\u724c\u4e3a\u7a7a',
|
zh: 'Bearer \u4ee4\u724c\u4e3a\u7a7a',
|
||||||
},
|
},
|
||||||
'auth.ttlMustBePositive': {
|
'auth.ttlMustBePositive': {
|
||||||
en: 'ttl_seconds must be a positive number',
|
en: 'ttl_seconds must be a whole number of seconds from 1 to 31536000',
|
||||||
zh: 'ttl_seconds \u5fc5\u987b\u4e3a\u6b63\u6570',
|
zh: 'ttl_seconds 必须是 1 到 31536000 之间的整数秒',
|
||||||
},
|
},
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -121,6 +118,14 @@ const messages: Record<string, Record<Lang, string>> = {
|
|||||||
en: 'ttl_seconds must be a number or null',
|
en: 'ttl_seconds must be a number or null',
|
||||||
zh: 'ttl_seconds \u5fc5\u987b\u4e3a\u6570\u5b57\u6216 null',
|
zh: 'ttl_seconds \u5fc5\u987b\u4e3a\u6570\u5b57\u6216 null',
|
||||||
},
|
},
|
||||||
|
'auth.issueRequiresSession': {
|
||||||
|
en: 'API token issuance requires a session credential.',
|
||||||
|
zh: '签发 API 令牌需要使用会话凭据。',
|
||||||
|
},
|
||||||
|
'auth.organizationRequiredForApiToken': {
|
||||||
|
en: 'Organization setup is required before issuing an API token.',
|
||||||
|
zh: '签发 API 令牌前需要完成组织设置。',
|
||||||
|
},
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Agent routes (src/routes/agents.ts)
|
// Agent routes (src/routes/agents.ts)
|
||||||
@@ -355,15 +360,7 @@ const messages: Record<string, Record<Lang, string>> = {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/** Retrieve a translated message by key. */
|
||||||
* 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(
|
export function getMessage(
|
||||||
key: string,
|
key: string,
|
||||||
lang: Lang = 'en',
|
lang: Lang = 'en',
|
||||||
@@ -378,12 +375,7 @@ export function getMessage(
|
|||||||
return msg;
|
return msg;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/** Detect the preferred language from an Accept-Language header value. */
|
||||||
* 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 {
|
export function detectLanguage(acceptLanguage: string | null | undefined): Lang {
|
||||||
if (!acceptLanguage) return 'en';
|
if (!acceptLanguage) return 'en';
|
||||||
if (acceptLanguage.includes('zh')) return 'zh';
|
if (acceptLanguage.includes('zh')) return 'zh';
|
||||||
|
|||||||
@@ -1,6 +1,4 @@
|
|||||||
/**
|
/** Elydora API: Node.js entry point. */
|
||||||
* Elydora API — Node.js entry point.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import 'dotenv/config';
|
import 'dotenv/config';
|
||||||
import { serve } from '@hono/node-server';
|
import { serve } from '@hono/node-server';
|
||||||
@@ -90,7 +88,7 @@ app.use('/*', async (c, next) => {
|
|||||||
await next();
|
await next();
|
||||||
});
|
});
|
||||||
|
|
||||||
// CORS — always restrict to the configured allowlist
|
// CORS: always restrict to the configured allowlist
|
||||||
app.use(
|
app.use(
|
||||||
'/*',
|
'/*',
|
||||||
cors({
|
cors({
|
||||||
|
|||||||
@@ -1,16 +1,4 @@
|
|||||||
/**
|
/** Better Auth configuration for ElydoraOpenSource. */
|
||||||
* Better Auth configuration for ElydoraOpenSource.
|
|
||||||
*
|
|
||||||
* Creates a Better Auth instance backed by PostgreSQL. The instance is
|
|
||||||
* configured to:
|
|
||||||
*
|
|
||||||
* - Use the existing `users` table via field-mapping (no schema change)
|
|
||||||
* - Support legacy PBKDF2-SHA256 password hashes alongside Better Auth's
|
|
||||||
* default scrypt so existing users can sign in without re-hashing
|
|
||||||
* - Expose bearer-token auth for API clients
|
|
||||||
* - Provide organization support with Elydora's 5-role RBAC hierarchy
|
|
||||||
* - Provide admin user-management endpoints
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { betterAuth } from 'better-auth';
|
import { betterAuth } from 'better-auth';
|
||||||
import { verifyPassword as verifyScryptPassword } from 'better-auth/crypto';
|
import { verifyPassword as verifyScryptPassword } from 'better-auth/crypto';
|
||||||
@@ -60,7 +48,7 @@ async function verifyLegacyPbkdf2(password: string, stored: string): Promise<boo
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// RBAC access control — maps Elydora's 5-role hierarchy to Better Auth
|
// RBAC access control: maps Elydora's 5-role hierarchy to Better Auth
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
const elydoraAc = createAccessControl({
|
const elydoraAc = createAccessControl({
|
||||||
@@ -121,7 +109,7 @@ const readonlyInvestigatorRole = elydoraAc.newRole({
|
|||||||
});
|
});
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Singleton — creates a Better Auth instance for the server process
|
// Singleton: creates a Better Auth instance for the server process
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
export function createAuth(databaseUrl: string, secret: string, baseUrl: string, allowedOrigins: string) {
|
export function createAuth(databaseUrl: string, secret: string, baseUrl: string, allowedOrigins: string) {
|
||||||
|
|||||||
@@ -1,28 +1,12 @@
|
|||||||
/**
|
/** Authentication middleware for opaque API tokens and Better Auth sessions. */
|
||||||
* Better Auth authentication middleware.
|
|
||||||
*
|
|
||||||
* Validates requests via Better Auth sessions (cookie or bearer token).
|
|
||||||
* On success, extracts `org_id`, `role`, and user `id` (actor) from the
|
|
||||||
* session and stores them in the Hono context variables for downstream
|
|
||||||
* handlers.
|
|
||||||
*
|
|
||||||
* Supports:
|
|
||||||
* - Session cookies (browser / console)
|
|
||||||
* - Bearer tokens via Better Auth's bearer plugin (SDK clients)
|
|
||||||
*/
|
|
||||||
|
|
||||||
import type { MiddlewareHandler } from 'hono';
|
import type { MiddlewareHandler } from 'hono';
|
||||||
import type { RbacRole } from '../shared/index.js';
|
import type { RbacRole } from '../shared/index.js';
|
||||||
import type { Env, AppVariables } from '../types.js';
|
import type { Env, AppVariables } from '../types.js';
|
||||||
import { AppError } from './error-handler.js';
|
import { AppError } from './error-handler.js';
|
||||||
import { createAuth } from '../lib/auth.js';
|
import { createAuth } from '../lib/auth.js';
|
||||||
|
import { authenticateApiToken } from '../services/api-token-service.js';
|
||||||
|
|
||||||
/**
|
|
||||||
* Authentication middleware.
|
|
||||||
*
|
|
||||||
* Uses Better Auth's getSession to validate the request via session
|
|
||||||
* cookie or bearer token. Sets org_id, role, and actor on the context.
|
|
||||||
*/
|
|
||||||
export const authMiddleware: MiddlewareHandler<{
|
export const authMiddleware: MiddlewareHandler<{
|
||||||
Bindings: Env;
|
Bindings: Env;
|
||||||
Variables: AppVariables;
|
Variables: AppVariables;
|
||||||
@@ -32,6 +16,19 @@ export const authMiddleware: MiddlewareHandler<{
|
|||||||
throw new AppError(401, 'UNAUTHORIZED', { key: 'auth.missingHeader' });
|
throw new AppError(401, 'UNAUTHORIZED', { key: 'auth.missingHeader' });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const bearerToken = authHeader.startsWith('Bearer ') ? authHeader.slice(7).trim() : '';
|
||||||
|
if (bearerToken) {
|
||||||
|
const apiToken = await authenticateApiToken(c.env.ELYDORA_DB, bearerToken);
|
||||||
|
if (apiToken) {
|
||||||
|
c.set('org_id', apiToken.org_id);
|
||||||
|
c.set('role', apiToken.role);
|
||||||
|
c.set('actor', apiToken.user_id);
|
||||||
|
c.set('auth_token_type', 'api');
|
||||||
|
await next();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
let session: Awaited<ReturnType<ReturnType<typeof createAuth>['api']['getSession']>>;
|
let session: Awaited<ReturnType<ReturnType<typeof createAuth>['api']['getSession']>>;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -57,6 +54,7 @@ export const authMiddleware: MiddlewareHandler<{
|
|||||||
c.set('org_id', user.org_id ?? '');
|
c.set('org_id', user.org_id ?? '');
|
||||||
c.set('role', ((user.role ?? 'readonly_investigator') as RbacRole));
|
c.set('role', ((user.role ?? 'readonly_investigator') as RbacRole));
|
||||||
c.set('actor', user.id);
|
c.set('actor', user.id);
|
||||||
|
c.set('auth_token_type', 'session');
|
||||||
|
|
||||||
await next();
|
await next();
|
||||||
};
|
};
|
||||||
@@ -1,15 +1,4 @@
|
|||||||
/**
|
/** Global error handler middleware. */
|
||||||
* 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 { ErrorHandler } from 'hono';
|
||||||
import type { ErrorCode, ErrorResponse } from '../shared/index.js';
|
import type { ErrorCode, ErrorResponse } from '../shared/index.js';
|
||||||
@@ -18,18 +7,14 @@ import type { Env, AppVariables } from '../types.js';
|
|||||||
import { getMessage } from '../i18n/messages.js';
|
import { getMessage } from '../i18n/messages.js';
|
||||||
import type { Lang } from '../i18n/messages.js';
|
import type { Lang } from '../i18n/messages.js';
|
||||||
|
|
||||||
/**
|
/** Maps each ErrorCode to its corresponding i18n message key. */
|
||||||
* 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> = {
|
const ERROR_CODE_TO_I18N_KEY: Record<ErrorCode, string> = {
|
||||||
INVALID_SIGNATURE: 'error.invalidSignature',
|
INVALID_SIGNATURE: 'error.invalidSignature',
|
||||||
UNKNOWN_AGENT: 'error.unknownAgent',
|
UNKNOWN_AGENT: 'error.unknownAgent',
|
||||||
KEY_REVOKED: 'error.keyRevoked',
|
KEY_REVOKED: 'error.keyRevoked',
|
||||||
|
KEY_RETIRED: 'error.keyRetired',
|
||||||
AGENT_FROZEN: 'error.agentFrozen',
|
AGENT_FROZEN: 'error.agentFrozen',
|
||||||
|
AGENT_REVOKED: 'error.agentRevoked',
|
||||||
TTL_EXPIRED: 'error.ttlExpired',
|
TTL_EXPIRED: 'error.ttlExpired',
|
||||||
REPLAY_DETECTED: 'error.replayDetected',
|
REPLAY_DETECTED: 'error.replayDetected',
|
||||||
PREV_HASH_MISMATCH: 'error.prevHashMismatch',
|
PREV_HASH_MISMATCH: 'error.prevHashMismatch',
|
||||||
@@ -42,16 +27,7 @@ const ERROR_CODE_TO_I18N_KEY: Record<ErrorCode, string> = {
|
|||||||
VALIDATION_ERROR: 'error.validationError',
|
VALIDATION_ERROR: 'error.validationError',
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/** Custom error class that carries an HTTP status and Elydora error code. */
|
||||||
* 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 {
|
export class AppError extends Error {
|
||||||
public readonly statusCode: number;
|
public readonly statusCode: number;
|
||||||
public readonly errorCode: ErrorCode;
|
public readonly errorCode: ErrorCode;
|
||||||
@@ -68,13 +44,13 @@ export class AppError extends Error {
|
|||||||
details?: Record<string, unknown>,
|
details?: Record<string, unknown>,
|
||||||
) {
|
) {
|
||||||
if (typeof messageOrOpts === 'object' && messageOrOpts !== null) {
|
if (typeof messageOrOpts === 'object' && messageOrOpts !== null) {
|
||||||
// i18n mode — store the key; resolve later in the error handler
|
// i18n mode: store the key; resolve later in the error handler
|
||||||
super(getMessage(messageOrOpts.key, 'en', messageOrOpts.params));
|
super(getMessage(messageOrOpts.key, 'en', messageOrOpts.params));
|
||||||
this.messageKey = messageOrOpts.key;
|
this.messageKey = messageOrOpts.key;
|
||||||
this.messageParams = messageOrOpts.params;
|
this.messageParams = messageOrOpts.params;
|
||||||
this.usesDefaultMessage = false;
|
this.usesDefaultMessage = false;
|
||||||
} else if (messageOrOpts === undefined) {
|
} else if (messageOrOpts === undefined) {
|
||||||
// Code-only mode — will be resolved via ERROR_CODE_TO_I18N_KEY
|
// Code-only mode: will be resolved via ERROR_CODE_TO_I18N_KEY
|
||||||
super(ERROR_CODES[errorCode]);
|
super(ERROR_CODES[errorCode]);
|
||||||
this.usesDefaultMessage = true;
|
this.usesDefaultMessage = true;
|
||||||
} else {
|
} else {
|
||||||
@@ -88,15 +64,7 @@ export class AppError extends Error {
|
|||||||
this.details = details;
|
this.details = details;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/** Resolve the user-facing message for a given language. */
|
||||||
* 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 {
|
resolveMessage(lang: Lang): string {
|
||||||
if (this.messageKey) {
|
if (this.messageKey) {
|
||||||
return getMessage(this.messageKey, lang, this.messageParams);
|
return getMessage(this.messageKey, lang, this.messageParams);
|
||||||
@@ -111,12 +79,7 @@ export class AppError extends Error {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/** Build a standardised error response body. */
|
||||||
* 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(
|
export function buildErrorResponse(
|
||||||
code: ErrorCode,
|
code: ErrorCode,
|
||||||
requestId: string,
|
requestId: string,
|
||||||
@@ -139,12 +102,7 @@ export function buildErrorResponse(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/** Hono error handler that converts all errors into the ErrorResponse format. */
|
||||||
* 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<{
|
export const globalErrorHandler: ErrorHandler<{
|
||||||
Bindings: Env;
|
Bindings: Env;
|
||||||
Variables: AppVariables;
|
Variables: AppVariables;
|
||||||
|
|||||||
@@ -1,11 +1,4 @@
|
|||||||
/**
|
/** i18n middleware: language detection from Accept-Language header. */
|
||||||
* 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 { MiddlewareHandler } from 'hono';
|
||||||
import type { Env, AppVariables } from '../types.js';
|
import type { Env, AppVariables } from '../types.js';
|
||||||
|
|||||||
@@ -1,25 +1,11 @@
|
|||||||
/**
|
/** Role-based access control (RBAC) middleware. */
|
||||||
* 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 { MiddlewareHandler } from 'hono';
|
||||||
import type { RbacRole } from '../shared/index.js';
|
import type { RbacRole } from '../shared/index.js';
|
||||||
import type { Env, AppVariables } from '../types.js';
|
import type { Env, AppVariables } from '../types.js';
|
||||||
import { AppError } from './error-handler.js';
|
import { AppError } from './error-handler.js';
|
||||||
|
|
||||||
/**
|
/** Privilege level map. */
|
||||||
* 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> = {
|
const ROLE_LEVEL: Record<RbacRole, number> = {
|
||||||
readonly_investigator: 10,
|
readonly_investigator: 10,
|
||||||
integration_engineer: 20,
|
integration_engineer: 20,
|
||||||
@@ -28,21 +14,12 @@ const ROLE_LEVEL: Record<RbacRole, number> = {
|
|||||||
org_owner: 50,
|
org_owner: 50,
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/** Check if a role has at least the given privilege level. */
|
||||||
* Check if a role has at least the given privilege level.
|
|
||||||
*/
|
|
||||||
function hasMinimumRole(userRole: RbacRole, requiredRole: RbacRole): boolean {
|
function hasMinimumRole(userRole: RbacRole, requiredRole: RbacRole): boolean {
|
||||||
return ROLE_LEVEL[userRole] >= ROLE_LEVEL[requiredRole];
|
return ROLE_LEVEL[userRole] >= ROLE_LEVEL[requiredRole];
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/** Create an RBAC middleware that requires a minimum role. */
|
||||||
* 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<{
|
export function requireRole(minimumRole: RbacRole): MiddlewareHandler<{
|
||||||
Bindings: Env;
|
Bindings: Env;
|
||||||
Variables: AppVariables;
|
Variables: AppVariables;
|
||||||
|
|||||||
@@ -1,10 +1,4 @@
|
|||||||
/**
|
/** Request ID middleware. */
|
||||||
* 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 { MiddlewareHandler } from 'hono';
|
||||||
import type { Env, AppVariables } from '../types.js';
|
import type { Env, AppVariables } from '../types.js';
|
||||||
|
|||||||
@@ -0,0 +1,49 @@
|
|||||||
|
import { readdir, readFile } from 'node:fs/promises';
|
||||||
|
import { join } from 'node:path';
|
||||||
|
import pg from 'pg';
|
||||||
|
|
||||||
|
const MIGRATIONS_DIR = process.env.MIGRATIONS_DIR ?? join(process.cwd(), 'migrations');
|
||||||
|
|
||||||
|
/** Leading digits of a migration filename, e.g. 004_x.sql becomes 4. */
|
||||||
|
function versionOf(file: string): number {
|
||||||
|
const match = /^(\d+)_/.exec(file);
|
||||||
|
if (!match) throw new Error(`Migration ${file} does not start with a version number.`);
|
||||||
|
return Number(match[1]);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function appliedVersions(client: pg.Client): Promise<Set<number>> {
|
||||||
|
const exists = await client.query("SELECT to_regclass('schema_versions') AS name");
|
||||||
|
if (!exists.rows[0]?.name) return new Set();
|
||||||
|
const rows = await client.query<{ version: string }>('SELECT version FROM schema_versions');
|
||||||
|
return new Set(rows.rows.map((row) => Number(row.version)));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main(): Promise<void> {
|
||||||
|
const connectionString = process.env.DATABASE_URL;
|
||||||
|
if (!connectionString) throw new Error('DATABASE_URL is required.');
|
||||||
|
|
||||||
|
const files = (await readdir(MIGRATIONS_DIR))
|
||||||
|
.filter((file) => file.endsWith('.sql'))
|
||||||
|
.sort((a, b) => versionOf(a) - versionOf(b));
|
||||||
|
|
||||||
|
const client = new pg.Client({ connectionString });
|
||||||
|
await client.connect();
|
||||||
|
try {
|
||||||
|
const applied = await appliedVersions(client);
|
||||||
|
for (const file of files) {
|
||||||
|
if (applied.has(versionOf(file))) {
|
||||||
|
console.log(`Skipping ${file} (already applied)`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
console.log(`Applying ${file}`);
|
||||||
|
await client.query(await readFile(join(MIGRATIONS_DIR, file), 'utf8'));
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
await client.end();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch((error) => {
|
||||||
|
console.error('migration failed:', error);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
@@ -1,6 +1,4 @@
|
|||||||
/**
|
/** Agent routes: registration, lookup, freezing, and key revocation. */
|
||||||
* Agent routes — registration, lookup, freezing, and key revocation.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { Hono } from 'hono';
|
import { Hono } from 'hono';
|
||||||
import type { Env, AppVariables } from '../types.js';
|
import type { Env, AppVariables } from '../types.js';
|
||||||
|
|||||||
@@ -1,10 +1,4 @@
|
|||||||
/**
|
/** Audit routes: query the tamper-evident audit log. */
|
||||||
* 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 { Hono } from 'hono';
|
||||||
import type { Env, AppVariables } from '../types.js';
|
import type { Env, AppVariables } from '../types.js';
|
||||||
@@ -19,7 +13,7 @@ const audit = new Hono<{ Bindings: Env; Variables: AppVariables }>();
|
|||||||
audit.use('/*', authMiddleware);
|
audit.use('/*', authMiddleware);
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// POST /v1/audit/query — Query the audit log
|
// POST /v1/audit/query: Query the audit log
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
audit.post(
|
audit.post(
|
||||||
'/query',
|
'/query',
|
||||||
|
|||||||
@@ -1,16 +1,10 @@
|
|||||||
/**
|
/** Auth routes: registration, login, profile retrieval, and token refresh. */
|
||||||
* Auth routes — registration, login, profile retrieval, and token refresh.
|
|
||||||
*
|
|
||||||
* /register and /login are public (no auth required).
|
|
||||||
* /me, /refresh, and /token require a valid Better Auth session.
|
|
||||||
*
|
|
||||||
* Uses Better Auth for session management while preserving the existing
|
|
||||||
* API surface (endpoint paths and response formats).
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { Hono } from 'hono';
|
import { Hono } from 'hono';
|
||||||
import type { Env, AppVariables } from '../types.js';
|
import type { Env, AppVariables } from '../types.js';
|
||||||
import { authMiddleware } from '../middleware/auth.js';
|
import { authMiddleware } from '../middleware/auth.js';
|
||||||
|
import { AppError } from '../middleware/error-handler.js';
|
||||||
|
import { issueApiToken, MAX_TTL_SECONDS } from '../services/api-token-service.js';
|
||||||
import { getMe } from '../services/auth-service.js';
|
import { getMe } from '../services/auth-service.js';
|
||||||
import { createAuth } from '../lib/auth.js';
|
import { createAuth } from '../lib/auth.js';
|
||||||
import { getMessage } from '../i18n/messages.js';
|
import { getMessage } from '../i18n/messages.js';
|
||||||
@@ -18,7 +12,7 @@ import { getMessage } from '../i18n/messages.js';
|
|||||||
const auth = new Hono<{ Bindings: Env; Variables: AppVariables }>();
|
const auth = new Hono<{ Bindings: Env; Variables: AppVariables }>();
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// POST /v1/auth/register — Create a new user and organization
|
// POST /v1/auth/register: Create a new user and organization
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
auth.post('/register', async (c) => {
|
auth.post('/register', async (c) => {
|
||||||
const body = await c.req.json();
|
const body = await c.req.json();
|
||||||
@@ -91,7 +85,7 @@ auth.post('/register', async (c) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// POST /v1/auth/login — Authenticate and receive a session token
|
// POST /v1/auth/login: Authenticate and receive a session token
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
auth.post('/login', async (c) => {
|
auth.post('/login', async (c) => {
|
||||||
const body = await c.req.json();
|
const body = await c.req.json();
|
||||||
@@ -132,7 +126,7 @@ auth.post('/login', async (c) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// GET /v1/auth/me — Get current user profile (auth required)
|
// GET /v1/auth/me: Get current user profile (auth required)
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
auth.get('/me', authMiddleware, async (c) => {
|
auth.get('/me', authMiddleware, async (c) => {
|
||||||
const userId = c.get('actor');
|
const userId = c.get('actor');
|
||||||
@@ -142,7 +136,7 @@ auth.get('/me', authMiddleware, async (c) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// POST /v1/auth/refresh — Issue a new session token (auth required)
|
// POST /v1/auth/refresh: Issue a new session token (auth required)
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
auth.post('/refresh', authMiddleware, async (c) => {
|
auth.post('/refresh', authMiddleware, async (c) => {
|
||||||
const betterAuthInstance = createAuth(
|
const betterAuthInstance = createAuth(
|
||||||
@@ -152,7 +146,7 @@ auth.post('/refresh', authMiddleware, async (c) => {
|
|||||||
c.env.ALLOWED_ORIGINS,
|
c.env.ALLOWED_ORIGINS,
|
||||||
);
|
);
|
||||||
|
|
||||||
// Get the current session and return its token — Better Auth handles
|
// Get the current session and return its token: Better Auth handles
|
||||||
// session refresh automatically based on the updateAge setting
|
// session refresh automatically based on the updateAge setting
|
||||||
const session = await betterAuthInstance.api.getSession({
|
const session = await betterAuthInstance.api.getSession({
|
||||||
headers: c.req.raw.headers,
|
headers: c.req.raw.headers,
|
||||||
@@ -169,46 +163,32 @@ auth.post('/refresh', authMiddleware, async (c) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// POST /v1/auth/token — Issue an API token with custom TTL (auth required)
|
// POST /v1/auth/token: Issue an opaque API token (session required)
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
auth.post('/token', authMiddleware, async (c) => {
|
auth.post('/token', authMiddleware, async (c) => {
|
||||||
|
if (c.get('auth_token_type') !== 'session') {
|
||||||
|
throw new AppError(400, 'VALIDATION_ERROR', { key: 'auth.issueRequiresSession' });
|
||||||
|
}
|
||||||
|
|
||||||
const body = await c.req.json();
|
const body = await c.req.json();
|
||||||
const { ttl_seconds } = body;
|
const { ttl_seconds } = body;
|
||||||
|
|
||||||
if (ttl_seconds !== null && ttl_seconds !== undefined) {
|
if (ttl_seconds !== null && ttl_seconds !== undefined) {
|
||||||
if (typeof ttl_seconds !== 'number' || ttl_seconds <= 0) {
|
if (typeof ttl_seconds !== 'number') {
|
||||||
const lang = c.get('lang') ?? 'en';
|
throw new AppError(400, 'VALIDATION_ERROR', { key: 'auth.ttlMustBeNumber' });
|
||||||
return c.json({ error: { code: 'VALIDATION_ERROR', message: getMessage('auth.ttlMustBeNumber', lang), request_id: '' } }, 400);
|
}
|
||||||
|
if (!Number.isInteger(ttl_seconds) || ttl_seconds <= 0 || ttl_seconds > MAX_TTL_SECONDS) {
|
||||||
|
throw new AppError(400, 'VALIDATION_ERROR', { key: 'auth.ttlMustBePositive' });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get the current session token — Better Auth sessions have a fixed
|
const orgId = c.get('org_id');
|
||||||
// lifetime configured in the auth instance (7 days by default).
|
if (!orgId) {
|
||||||
// The ttl_seconds parameter is accepted for API compatibility but
|
throw new AppError(409, 'VALIDATION_ERROR', { key: 'auth.organizationRequiredForApiToken' });
|
||||||
// session lifetime is governed by Better Auth's session config.
|
|
||||||
const betterAuthInstance = createAuth(
|
|
||||||
process.env.DATABASE_URL!,
|
|
||||||
c.env.BETTER_AUTH_SECRET,
|
|
||||||
c.env.BETTER_AUTH_URL,
|
|
||||||
c.env.ALLOWED_ORIGINS,
|
|
||||||
);
|
|
||||||
|
|
||||||
const session = await betterAuthInstance.api.getSession({
|
|
||||||
headers: c.req.raw.headers,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!session) {
|
|
||||||
const lang = c.get('lang') ?? 'en';
|
|
||||||
return c.json({ error: { code: 'UNAUTHORIZED', message: getMessage('auth.invalidSession', lang), request_id: '' } }, 401);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const sessionData = session.session as { token?: string; expiresAt?: Date };
|
const issued = await issueApiToken(c.env.ELYDORA_DB, c.get('actor'), orgId, ttl_seconds ?? null);
|
||||||
const token = sessionData.token ?? '';
|
return c.json({ token: issued.token, expires_at: issued.expires_at }, 200);
|
||||||
const expiresAt = sessionData.expiresAt
|
|
||||||
? Math.floor(new Date(sessionData.expiresAt).getTime() / 1000)
|
|
||||||
: Math.floor(Date.now() / 1000) + 604800;
|
|
||||||
|
|
||||||
return c.json({ token, expires_at: expiresAt }, 200);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
export { auth };
|
export { auth };
|
||||||
@@ -1,6 +1,4 @@
|
|||||||
/**
|
/** Epoch routes: retrieve epoch root records with optional TSA anchors. */
|
||||||
* Epoch routes — retrieve epoch root records with optional TSA anchors.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { Hono } from 'hono';
|
import { Hono } from 'hono';
|
||||||
import type { Env, AppVariables } from '../types.js';
|
import type { Env, AppVariables } from '../types.js';
|
||||||
@@ -14,7 +12,7 @@ const epochs = new Hono<{ Bindings: Env; Variables: AppVariables }>();
|
|||||||
epochs.use('/*', authMiddleware);
|
epochs.use('/*', authMiddleware);
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// GET /v1/epochs — List all epochs for the organization
|
// GET /v1/epochs: List all epochs for the organization
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
epochs.get('/', requireRole('readonly_investigator'), async (c) => {
|
epochs.get('/', requireRole('readonly_investigator'), async (c) => {
|
||||||
const orgId = c.get('org_id');
|
const orgId = c.get('org_id');
|
||||||
@@ -24,7 +22,7 @@ epochs.get('/', requireRole('readonly_investigator'), async (c) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// GET /v1/epochs/:epoch_id — Retrieve an epoch root
|
// GET /v1/epochs/:epoch_id: Retrieve an epoch root
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
epochs.get(
|
epochs.get(
|
||||||
'/:epoch_id',
|
'/:epoch_id',
|
||||||
|
|||||||
@@ -1,6 +1,4 @@
|
|||||||
/**
|
/** Export routes: create and retrieve compliance export jobs. */
|
||||||
* Export routes — create and retrieve compliance export jobs.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { Hono } from 'hono';
|
import { Hono } from 'hono';
|
||||||
import type { Env, AppVariables } from '../types.js';
|
import type { Env, AppVariables } from '../types.js';
|
||||||
@@ -16,7 +14,7 @@ const exports_ = new Hono<{ Bindings: Env; Variables: AppVariables }>();
|
|||||||
exports_.use('/*', authMiddleware);
|
exports_.use('/*', authMiddleware);
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// GET /v1/exports — List all exports for the organization
|
// GET /v1/exports: List all exports for the organization
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
exports_.get('/', requireRole('compliance_auditor'), async (c) => {
|
exports_.get('/', requireRole('compliance_auditor'), async (c) => {
|
||||||
const orgId = c.get('org_id');
|
const orgId = c.get('org_id');
|
||||||
@@ -26,7 +24,7 @@ exports_.get('/', requireRole('compliance_auditor'), async (c) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// POST /v1/exports — Create a compliance export job
|
// POST /v1/exports: Create a compliance export job
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
exports_.post(
|
exports_.post(
|
||||||
'/',
|
'/',
|
||||||
@@ -55,7 +53,7 @@ exports_.post(
|
|||||||
);
|
);
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// GET /v1/exports/:export_id — Retrieve export status + download URL
|
// GET /v1/exports/:export_id: Retrieve export status + download URL
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
exports_.get(
|
exports_.get(
|
||||||
'/:export_id',
|
'/:export_id',
|
||||||
@@ -77,7 +75,7 @@ exports_.get(
|
|||||||
);
|
);
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// GET /v1/exports/:export_id/download — Stream the export file from R2
|
// GET /v1/exports/:export_id/download: Stream the export file from R2
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
exports_.get(
|
exports_.get(
|
||||||
'/:export_id/download',
|
'/:export_id/download',
|
||||||
|
|||||||
@@ -1,13 +1,4 @@
|
|||||||
/**
|
/** JWKS route: public key discovery endpoint. */
|
||||||
* 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 { Hono } from 'hono';
|
||||||
import type { Env, AppVariables } from '../types.js';
|
import type { Env, AppVariables } from '../types.js';
|
||||||
@@ -17,7 +8,7 @@ import { deriveEd25519PublicKey } from '../utils/crypto.js';
|
|||||||
const jwks = new Hono<{ Bindings: Env; Variables: AppVariables }>();
|
const jwks = new Hono<{ Bindings: Env; Variables: AppVariables }>();
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// GET /.well-known/elydora/jwks.json — Retrieve the platform JWKS
|
// GET /.well-known/elydora/jwks.json: Retrieve the platform JWKS
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
jwks.get('/', async (c) => {
|
jwks.get('/', async (c) => {
|
||||||
// Derive the public key from the server signing key
|
// Derive the public key from the server signing key
|
||||||
|
|||||||
@@ -1,6 +1,4 @@
|
|||||||
/**
|
/** Operation routes: submit, retrieve, and verify signed operation records. */
|
||||||
* Operation routes — submit, retrieve, and verify signed operation records.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { Hono } from 'hono';
|
import { Hono } from 'hono';
|
||||||
import type { Env, AppVariables } from '../types.js';
|
import type { Env, AppVariables } from '../types.js';
|
||||||
@@ -13,7 +11,7 @@ import * as operationService from '../services/operation-service.js';
|
|||||||
const operations = new Hono<{ Bindings: Env; Variables: AppVariables }>();
|
const operations = new Hono<{ Bindings: Env; Variables: AppVariables }>();
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// POST /v1/operations — Submit a signed EOR
|
// POST /v1/operations: Submit a signed EOR
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Operation submission uses the EOR's embedded signature for authentication
|
// Operation submission uses the EOR's embedded signature for authentication
|
||||||
// rather than a Bearer token. The agent authenticates via its Ed25519 signature.
|
// rather than a Bearer token. The agent authenticates via its Ed25519 signature.
|
||||||
@@ -47,7 +45,7 @@ operations.post(
|
|||||||
);
|
);
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// GET /v1/operations/:operation_id — Retrieve an operation
|
// GET /v1/operations/:operation_id: Retrieve an operation
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
operations.get(
|
operations.get(
|
||||||
'/:operation_id',
|
'/:operation_id',
|
||||||
@@ -65,7 +63,7 @@ operations.get(
|
|||||||
);
|
);
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// POST /v1/operations/:operation_id/verify — Verify operation integrity
|
// POST /v1/operations/:operation_id/verify: Verify operation integrity
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
operations.post(
|
operations.post(
|
||||||
'/:operation_id/verify',
|
'/:operation_id/verify',
|
||||||
|
|||||||
@@ -1,8 +1,4 @@
|
|||||||
/**
|
/** Epoch scheduler: runs every 5 minutes to create Merkle tree rollups. */
|
||||||
* Epoch scheduler — runs every 5 minutes to create Merkle tree rollups.
|
|
||||||
*
|
|
||||||
* Run as a separate Node.js process: node dist/scheduler.js
|
|
||||||
*/
|
|
||||||
|
|
||||||
import 'dotenv/config';
|
import 'dotenv/config';
|
||||||
import cron from 'node-cron';
|
import cron from 'node-cron';
|
||||||
|
|||||||
@@ -1,9 +1,4 @@
|
|||||||
/**
|
/** Agent service: business logic for agent lifecycle management. */
|
||||||
* Agent service — business logic for agent lifecycle management.
|
|
||||||
*
|
|
||||||
* Handles registration, lookup, freezing, and key revocation. All
|
|
||||||
* administrative mutations are recorded as admin_events for audit.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import type {
|
import type {
|
||||||
Agent,
|
Agent,
|
||||||
|
|||||||
@@ -0,0 +1,85 @@
|
|||||||
|
/** Opaque API tokens: the raw value is returned once; only its SHA-256 hash is stored. */
|
||||||
|
|
||||||
|
import { createHash, randomBytes } from 'node:crypto';
|
||||||
|
import type { Database } from '../adapters/interfaces.js';
|
||||||
|
import type { RbacRole } from '../shared/index.js';
|
||||||
|
import { generateUUIDv7 } from '../utils/uuid.js';
|
||||||
|
|
||||||
|
export const MAX_TTL_SECONDS = 31_536_000;
|
||||||
|
|
||||||
|
interface ApiTokenAuthRow {
|
||||||
|
readonly token_id: string;
|
||||||
|
readonly user_id: string;
|
||||||
|
readonly org_id: string;
|
||||||
|
readonly expires_at: number | null;
|
||||||
|
readonly user_org_id: string | null;
|
||||||
|
readonly user_role: RbacRole;
|
||||||
|
readonly user_status: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AuthenticatedApiToken {
|
||||||
|
readonly token_id: string;
|
||||||
|
readonly user_id: string;
|
||||||
|
readonly org_id: string;
|
||||||
|
readonly role: RbacRole;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IssuedApiToken {
|
||||||
|
readonly token: string;
|
||||||
|
readonly token_id: string;
|
||||||
|
readonly expires_at: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function hashToken(token: string): string {
|
||||||
|
return createHash('sha256').update(token).digest('hex');
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function authenticateApiToken(
|
||||||
|
db: Database,
|
||||||
|
rawToken: string,
|
||||||
|
): Promise<AuthenticatedApiToken | null> {
|
||||||
|
const nowSeconds = Math.floor(Date.now() / 1000);
|
||||||
|
const row = await db
|
||||||
|
.prepare(
|
||||||
|
`SELECT t.token_id, t.user_id, t.org_id, t.expires_at,
|
||||||
|
u.org_id AS user_org_id, u.role AS user_role, u.status AS user_status
|
||||||
|
FROM api_tokens AS t
|
||||||
|
JOIN users AS u ON u.user_id = t.user_id
|
||||||
|
WHERE t.token_hash = ?`,
|
||||||
|
)
|
||||||
|
.bind(hashToken(rawToken))
|
||||||
|
.first<ApiTokenAuthRow>();
|
||||||
|
|
||||||
|
if (!row) return null;
|
||||||
|
if (row.expires_at !== null && row.expires_at <= nowSeconds) return null;
|
||||||
|
if (row.user_status !== 'active' || row.user_org_id !== row.org_id) return null;
|
||||||
|
|
||||||
|
return {
|
||||||
|
token_id: row.token_id,
|
||||||
|
user_id: row.user_id,
|
||||||
|
org_id: row.org_id,
|
||||||
|
role: row.user_role,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function issueApiToken(
|
||||||
|
db: Database,
|
||||||
|
userId: string,
|
||||||
|
orgId: string,
|
||||||
|
ttlSeconds: number | null,
|
||||||
|
): Promise<IssuedApiToken> {
|
||||||
|
const nowSeconds = Math.floor(Date.now() / 1000);
|
||||||
|
const expiresAt = ttlSeconds === null ? null : nowSeconds + ttlSeconds;
|
||||||
|
const tokenId = generateUUIDv7();
|
||||||
|
const token = randomBytes(32).toString('base64url');
|
||||||
|
|
||||||
|
await db
|
||||||
|
.prepare(
|
||||||
|
`INSERT INTO api_tokens (token_id, user_id, org_id, token_hash, issued_at, expires_at)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?)`,
|
||||||
|
)
|
||||||
|
.bind(tokenId, userId, orgId, hashToken(token), nowSeconds, expiresAt)
|
||||||
|
.run();
|
||||||
|
|
||||||
|
return { token, token_id: tokenId, expires_at: expiresAt };
|
||||||
|
}
|
||||||
@@ -1,10 +1,4 @@
|
|||||||
/**
|
/** Audit service: business logic for querying the tamper-evident audit log. */
|
||||||
* Audit service — business logic for querying the tamper-evident audit log.
|
|
||||||
*
|
|
||||||
* Builds dynamic SQL queries with optional filters (org_id, agent_id,
|
|
||||||
* operation_type, time range) and supports cursor-based pagination for
|
|
||||||
* efficient traversal of large result sets.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import type { Operation, AuditQueryRequest, AuditQueryResponse } from '../shared/index.js';
|
import type { Operation, AuditQueryRequest, AuditQueryResponse } from '../shared/index.js';
|
||||||
import { DEFAULT_QUERY_LIMIT, MAX_QUERY_LIMIT } from '../shared/index.js';
|
import { DEFAULT_QUERY_LIMIT, MAX_QUERY_LIMIT } from '../shared/index.js';
|
||||||
|
|||||||
@@ -1,10 +1,4 @@
|
|||||||
/**
|
/** Auth service: password hashing and user profile retrieval. */
|
||||||
* Auth service — password hashing and user profile retrieval.
|
|
||||||
*
|
|
||||||
* Authentication is handled by Better Auth (see lib/auth.ts).
|
|
||||||
* This service provides password utilities for legacy PBKDF2 hash
|
|
||||||
* verification and the getMe profile query.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { AppError } from '../middleware/error-handler.js';
|
import { AppError } from '../middleware/error-handler.js';
|
||||||
import type { RbacRole } from '../shared/index.js';
|
import type { RbacRole } from '../shared/index.js';
|
||||||
|
|||||||
@@ -1,11 +1,4 @@
|
|||||||
/**
|
/** Epoch service: business logic for retrieving epoch root records. */
|
||||||
* Epoch service — business logic for retrieving epoch root records.
|
|
||||||
*
|
|
||||||
* Epochs are periodic Merkle-tree rollups that anchor a set of operations
|
|
||||||
* to a single root hash. Optionally, the epoch may carry a TSA
|
|
||||||
* (Timestamping Authority) anchor that binds the root to an external
|
|
||||||
* trusted timestamp for non-repudiation.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import type { Epoch, EER, GetEpochResponse, ListEpochsResponse } from '../shared/index.js';
|
import type { Epoch, EER, GetEpochResponse, ListEpochsResponse } from '../shared/index.js';
|
||||||
import { AppError } from '../middleware/error-handler.js';
|
import { AppError } from '../middleware/error-handler.js';
|
||||||
@@ -82,13 +75,7 @@ export async function getEpoch(
|
|||||||
// Create epoch
|
// Create epoch
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
/**
|
/** Create a new epoch by building a Merkle tree over operations in a time window, signing the Epoch Record, and p */
|
||||||
* Create a new epoch by building a Merkle tree over operations in a time
|
|
||||||
* window, signing the Epoch Record, and persisting to D1 + R2.
|
|
||||||
*
|
|
||||||
* Returns null if the epoch already exists, or if no operations fall within
|
|
||||||
* the given time range.
|
|
||||||
*/
|
|
||||||
export async function createEpoch(
|
export async function createEpoch(
|
||||||
db: Database,
|
db: Database,
|
||||||
r2: ObjectStore,
|
r2: ObjectStore,
|
||||||
@@ -97,7 +84,7 @@ export async function createEpoch(
|
|||||||
startTime: number,
|
startTime: number,
|
||||||
endTime: number,
|
endTime: number,
|
||||||
): Promise<Epoch | null> {
|
): Promise<Epoch | null> {
|
||||||
// 1. Idempotency check — skip if epoch already exists for this window
|
// 1. Idempotency check: skip if epoch already exists for this window
|
||||||
const existing = await db
|
const existing = await db
|
||||||
.prepare(
|
.prepare(
|
||||||
'SELECT epoch_id FROM epochs WHERE org_id = ? AND start_time = ? AND end_time = ?',
|
'SELECT epoch_id FROM epochs WHERE org_id = ? AND start_time = ? AND end_time = ?',
|
||||||
|
|||||||
@@ -1,12 +1,4 @@
|
|||||||
/**
|
/** Export service: business logic for compliance export job management. */
|
||||||
* Export service — business logic for compliance export job management.
|
|
||||||
*
|
|
||||||
* Exports are asynchronous jobs that package operations matching specified
|
|
||||||
* criteria into a downloadable bundle (JSON or PDF). The job is enqueued
|
|
||||||
* via a message queue and processed by the queue consumer. Once complete,
|
|
||||||
* the result is stored in the object store and the database record is
|
|
||||||
* updated with a reference to the object key.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import type { Export, CreateExportRequest, CreateExportResponse, GetExportResponse, ListExportsResponse } from '../shared/index.js';
|
import type { Export, CreateExportRequest, CreateExportResponse, GetExportResponse, ListExportsResponse } from '../shared/index.js';
|
||||||
import { generateUUIDv7 } from '../utils/uuid.js';
|
import { generateUUIDv7 } from '../utils/uuid.js';
|
||||||
|
|||||||
@@ -0,0 +1,82 @@
|
|||||||
|
import type { EOR } from '../shared/index.js';
|
||||||
|
import { MAX_PAYLOAD_SIZE, MAX_TTL_MS, MIN_TTL_MS, MAX_NONCE_LENGTH } from '../shared/index.js';
|
||||||
|
import { AppError } from '../middleware/error-handler.js';
|
||||||
|
|
||||||
|
/** Genesis chain hash for an agent's first operation. */
|
||||||
|
export const GENESIS_CHAIN_HASH = 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA';
|
||||||
|
|
||||||
|
/** Server key ID carried in receipts. */
|
||||||
|
export const ELYDORA_KID = 'elydora-server-key-v1';
|
||||||
|
|
||||||
|
/** The EOR fields covered by the agent signature. */
|
||||||
|
export function buildSignableEOR(eor: EOR): Record<string, unknown> {
|
||||||
|
return {
|
||||||
|
op_version: eor.op_version,
|
||||||
|
operation_id: eor.operation_id,
|
||||||
|
org_id: eor.org_id,
|
||||||
|
agent_id: eor.agent_id,
|
||||||
|
issued_at: eor.issued_at,
|
||||||
|
ttl_ms: eor.ttl_ms,
|
||||||
|
nonce: eor.nonce,
|
||||||
|
operation_type: eor.operation_type,
|
||||||
|
subject: eor.subject,
|
||||||
|
action: eor.action,
|
||||||
|
payload: eor.payload,
|
||||||
|
payload_hash: eor.payload_hash,
|
||||||
|
prev_chain_hash: eor.prev_chain_hash,
|
||||||
|
agent_pubkey_kid: eor.agent_pubkey_kid,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function validateEORFields(eor: EOR, receivedAt: number): void {
|
||||||
|
if (eor.op_version !== '1.0') {
|
||||||
|
throw new AppError(400, 'VALIDATION_ERROR', { key: 'operation.unsupportedVersion', params: { version: eor.op_version } });
|
||||||
|
}
|
||||||
|
|
||||||
|
const requiredStrings: Array<[string, unknown]> = [
|
||||||
|
['operation_id', eor.operation_id],
|
||||||
|
['org_id', eor.org_id],
|
||||||
|
['agent_id', eor.agent_id],
|
||||||
|
['nonce', eor.nonce],
|
||||||
|
['operation_type', eor.operation_type],
|
||||||
|
['payload_hash', eor.payload_hash],
|
||||||
|
['prev_chain_hash', eor.prev_chain_hash],
|
||||||
|
['agent_pubkey_kid', eor.agent_pubkey_kid],
|
||||||
|
['signature', eor.signature],
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const [field, value] of requiredStrings) {
|
||||||
|
if (!value || typeof value !== 'string' || value.trim().length === 0) {
|
||||||
|
throw new AppError(400, 'VALIDATION_ERROR', { key: 'operation.missingField', params: { field } });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (eor.nonce.length > MAX_NONCE_LENGTH) {
|
||||||
|
throw new AppError(400, 'VALIDATION_ERROR', { key: 'operation.nonceTooLong', params: { max: MAX_NONCE_LENGTH } });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof eor.issued_at !== 'number' || eor.issued_at <= 0) {
|
||||||
|
throw new AppError(400, 'VALIDATION_ERROR', { key: 'operation.invalidIssuedAt' });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof eor.ttl_ms !== 'number') {
|
||||||
|
throw new AppError(400, 'VALIDATION_ERROR', { key: 'operation.missingTtl' });
|
||||||
|
}
|
||||||
|
if (eor.ttl_ms < MIN_TTL_MS) {
|
||||||
|
throw new AppError(400, 'VALIDATION_ERROR', { key: 'operation.ttlTooLow', params: { min: MIN_TTL_MS } });
|
||||||
|
}
|
||||||
|
if (eor.ttl_ms > MAX_TTL_MS) {
|
||||||
|
throw new AppError(400, 'VALIDATION_ERROR', { key: 'operation.ttlTooHigh', params: { max: MAX_TTL_MS } });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (eor.issued_at + eor.ttl_ms < receivedAt) {
|
||||||
|
throw new AppError(400, 'TTL_EXPIRED');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (eor.payload !== null && eor.payload !== undefined) {
|
||||||
|
const payloadStr = typeof eor.payload === 'string' ? eor.payload : JSON.stringify(eor.payload);
|
||||||
|
if (new TextEncoder().encode(payloadStr).byteLength > MAX_PAYLOAD_SIZE) {
|
||||||
|
throw new AppError(400, 'PAYLOAD_TOO_LARGE');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,328 +1,9 @@
|
|||||||
/**
|
import type { Operation, Receipt, GetOperationResponse } from '../shared/index.js';
|
||||||
* Operation service — business logic for EOR submission, retrieval, and verification.
|
|
||||||
*
|
|
||||||
* This is the core of the Elydora protocol implementation. It performs:
|
|
||||||
* 1. EOR field validation
|
|
||||||
* 2. Agent and key status checks
|
|
||||||
* 3. Ed25519 signature verification
|
|
||||||
* 4. Hash chain validation and computation
|
|
||||||
* 5. Persistence to D1 and R2
|
|
||||||
* 6. Queue enqueue for async processing
|
|
||||||
* 7. EAR (receipt) generation with server signature
|
|
||||||
*/
|
|
||||||
|
|
||||||
import type {
|
|
||||||
EOR,
|
|
||||||
EAR,
|
|
||||||
Agent,
|
|
||||||
AgentKey,
|
|
||||||
Operation,
|
|
||||||
Receipt,
|
|
||||||
GetOperationResponse,
|
|
||||||
VerifyOperationResponse,
|
|
||||||
} from '../shared/index.js';
|
|
||||||
import {
|
|
||||||
MAX_PAYLOAD_SIZE,
|
|
||||||
MAX_TTL_MS,
|
|
||||||
MIN_TTL_MS,
|
|
||||||
MAX_NONCE_LENGTH,
|
|
||||||
} from '../shared/index.js';
|
|
||||||
import {
|
|
||||||
base64urlDecode,
|
|
||||||
verifyEd25519Signature,
|
|
||||||
jcsCanonicalise,
|
|
||||||
computeChainHash,
|
|
||||||
computeReceiptHash,
|
|
||||||
signEd25519,
|
|
||||||
} from '../utils/crypto.js';
|
|
||||||
import { generateUUIDv7 } from '../utils/uuid.js';
|
|
||||||
import { AppError } from '../middleware/error-handler.js';
|
import { AppError } from '../middleware/error-handler.js';
|
||||||
import type { Database, ObjectStore, Cache, MessageQueue, PreparedStatement } from '../adapters/interfaces.js';
|
import type { Database, ObjectStore } from '../adapters/interfaces.js';
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
export { submitOperation } from './operation-submit.js';
|
||||||
// Constants
|
export { verifyOperation } from './operation-verify.js';
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
/** The genesis chain hash used for an agent's first operation. */
|
|
||||||
const GENESIS_CHAIN_HASH = 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA';
|
|
||||||
|
|
||||||
/** Elydora server key ID used in receipts. */
|
|
||||||
const ELYDORA_KID = 'elydora-server-key-v1';
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Submit operation
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
export async function submitOperation(
|
|
||||||
db: Database,
|
|
||||||
r2: ObjectStore,
|
|
||||||
kv: Cache,
|
|
||||||
queue: MessageQueue,
|
|
||||||
eor: EOR,
|
|
||||||
signingKey: string,
|
|
||||||
): Promise<{ receipt: EAR }> {
|
|
||||||
const receivedAt = Date.now();
|
|
||||||
|
|
||||||
// ------------------------------------------------------------------
|
|
||||||
// Step 1: Validate EOR fields
|
|
||||||
// ------------------------------------------------------------------
|
|
||||||
validateEORFields(eor, receivedAt);
|
|
||||||
|
|
||||||
// ------------------------------------------------------------------
|
|
||||||
// Step 2: Replay detection via nonce in KV
|
|
||||||
// ------------------------------------------------------------------
|
|
||||||
const nonceKey = `nonce:${eor.org_id}:${eor.nonce}`;
|
|
||||||
const existingNonce = await kv.get(nonceKey);
|
|
||||||
if (existingNonce) {
|
|
||||||
throw new AppError(400, 'REPLAY_DETECTED');
|
|
||||||
}
|
|
||||||
// Store nonce with TTL equal to the operation's TTL (in seconds)
|
|
||||||
const nonceTtlSeconds = Math.ceil(eor.ttl_ms / 1000);
|
|
||||||
await kv.put(nonceKey, '1', { expirationTtl: nonceTtlSeconds });
|
|
||||||
|
|
||||||
// ------------------------------------------------------------------
|
|
||||||
// Step 3: Look up agent and check status
|
|
||||||
// ------------------------------------------------------------------
|
|
||||||
const agent = await db
|
|
||||||
.prepare('SELECT * FROM agents WHERE agent_id = ? AND org_id = ?')
|
|
||||||
.bind(eor.agent_id, eor.org_id)
|
|
||||||
.first<Agent>();
|
|
||||||
|
|
||||||
if (!agent) {
|
|
||||||
throw new AppError(404, 'UNKNOWN_AGENT', { key: 'operation.agentNotRegistered', params: { id: eor.agent_id } });
|
|
||||||
}
|
|
||||||
|
|
||||||
if (agent.status === 'frozen') {
|
|
||||||
throw new AppError(403, 'AGENT_FROZEN');
|
|
||||||
}
|
|
||||||
|
|
||||||
if (agent.status === 'revoked') {
|
|
||||||
throw new AppError(403, 'AGENT_FROZEN', { key: 'operation.agentRevoked' });
|
|
||||||
}
|
|
||||||
|
|
||||||
// ------------------------------------------------------------------
|
|
||||||
// Step 4: Look up key and check status
|
|
||||||
// ------------------------------------------------------------------
|
|
||||||
const agentKey = await db
|
|
||||||
.prepare('SELECT * FROM agent_keys WHERE kid = ? AND agent_id = ?')
|
|
||||||
.bind(eor.agent_pubkey_kid, eor.agent_id)
|
|
||||||
.first<AgentKey>();
|
|
||||||
|
|
||||||
if (!agentKey) {
|
|
||||||
throw new AppError(400, 'INVALID_SIGNATURE', { key: 'operation.keyNotFoundForAgent', params: { kid: eor.agent_pubkey_kid, id: eor.agent_id } });
|
|
||||||
}
|
|
||||||
|
|
||||||
if (agentKey.status === 'revoked') {
|
|
||||||
throw new AppError(403, 'KEY_REVOKED');
|
|
||||||
}
|
|
||||||
|
|
||||||
if (agentKey.status === 'retired') {
|
|
||||||
throw new AppError(403, 'KEY_REVOKED', { key: 'operation.keyRetired' });
|
|
||||||
}
|
|
||||||
|
|
||||||
// ------------------------------------------------------------------
|
|
||||||
// Step 5: Verify Ed25519 signature
|
|
||||||
// ------------------------------------------------------------------
|
|
||||||
const signableEOR = buildSignableEOR(eor);
|
|
||||||
const canonicalData = jcsCanonicalise(signableEOR);
|
|
||||||
const dataBytes = new TextEncoder().encode(canonicalData);
|
|
||||||
const signatureBytes = base64urlDecode(eor.signature);
|
|
||||||
const publicKeyBytes = base64urlDecode(agentKey.public_key);
|
|
||||||
|
|
||||||
const signatureValid = await verifyEd25519Signature(publicKeyBytes, signatureBytes, dataBytes);
|
|
||||||
|
|
||||||
if (!signatureValid) {
|
|
||||||
throw new AppError(400, 'INVALID_SIGNATURE');
|
|
||||||
}
|
|
||||||
|
|
||||||
// ------------------------------------------------------------------
|
|
||||||
// Step 6: Verify prev_chain_hash against the latest chain_hash
|
|
||||||
// ------------------------------------------------------------------
|
|
||||||
const latestOp = await db
|
|
||||||
.prepare(
|
|
||||||
'SELECT chain_hash, seq_no FROM operations WHERE agent_id = ? ORDER BY seq_no DESC LIMIT 1',
|
|
||||||
)
|
|
||||||
.bind(eor.agent_id)
|
|
||||||
.first<{ chain_hash: string; seq_no: number }>();
|
|
||||||
|
|
||||||
const expectedPrevHash = latestOp ? latestOp.chain_hash : GENESIS_CHAIN_HASH;
|
|
||||||
const nextSeqNo = latestOp ? latestOp.seq_no + 1 : 1;
|
|
||||||
|
|
||||||
if (eor.prev_chain_hash !== expectedPrevHash) {
|
|
||||||
throw new AppError(400, 'PREV_HASH_MISMATCH', { key: 'operation.prevHashMismatch', params: { expected: expectedPrevHash, actual: eor.prev_chain_hash } });
|
|
||||||
}
|
|
||||||
|
|
||||||
// ------------------------------------------------------------------
|
|
||||||
// Step 7: Compute new chain_hash
|
|
||||||
// ------------------------------------------------------------------
|
|
||||||
const chainHash = await computeChainHash(
|
|
||||||
eor.prev_chain_hash,
|
|
||||||
eor.payload_hash,
|
|
||||||
eor.operation_id,
|
|
||||||
eor.issued_at,
|
|
||||||
);
|
|
||||||
|
|
||||||
// ------------------------------------------------------------------
|
|
||||||
// Step 8: Store operation in D1
|
|
||||||
// ------------------------------------------------------------------
|
|
||||||
const r2PayloadKey = `${eor.org_id}/${eor.agent_id}/${eor.operation_id}`;
|
|
||||||
|
|
||||||
const operation: Operation = {
|
|
||||||
operation_id: eor.operation_id,
|
|
||||||
org_id: eor.org_id,
|
|
||||||
agent_id: eor.agent_id,
|
|
||||||
seq_no: nextSeqNo,
|
|
||||||
operation_type: eor.operation_type,
|
|
||||||
issued_at: eor.issued_at,
|
|
||||||
ttl_ms: eor.ttl_ms,
|
|
||||||
nonce: eor.nonce,
|
|
||||||
subject: JSON.stringify(eor.subject),
|
|
||||||
action: JSON.stringify(eor.action),
|
|
||||||
payload_hash: eor.payload_hash,
|
|
||||||
prev_chain_hash: eor.prev_chain_hash,
|
|
||||||
chain_hash: chainHash,
|
|
||||||
agent_pubkey_kid: eor.agent_pubkey_kid,
|
|
||||||
signature: eor.signature,
|
|
||||||
r2_payload_key: r2PayloadKey,
|
|
||||||
created_at: receivedAt,
|
|
||||||
};
|
|
||||||
|
|
||||||
// ------------------------------------------------------------------
|
|
||||||
// Step 9: Store the full EOR envelope in R2
|
|
||||||
// ------------------------------------------------------------------
|
|
||||||
const eorJson = JSON.stringify(eor);
|
|
||||||
await r2.put(r2PayloadKey, eorJson, {
|
|
||||||
httpMetadata: { contentType: 'application/json' },
|
|
||||||
customMetadata: {
|
|
||||||
org_id: eor.org_id,
|
|
||||||
agent_id: eor.agent_id,
|
|
||||||
operation_type: eor.operation_type,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
// ------------------------------------------------------------------
|
|
||||||
// Step 10: Reserve the queue message ID; the send follows the commit
|
|
||||||
// ------------------------------------------------------------------
|
|
||||||
const queueMessageId = generateUUIDv7();
|
|
||||||
const receiptId = generateUUIDv7();
|
|
||||||
|
|
||||||
// ------------------------------------------------------------------
|
|
||||||
// Step 11: Generate EAR receipt
|
|
||||||
// ------------------------------------------------------------------
|
|
||||||
const receiptFields = {
|
|
||||||
receipt_version: '1.0',
|
|
||||||
receipt_id: receiptId,
|
|
||||||
operation_id: eor.operation_id,
|
|
||||||
org_id: eor.org_id,
|
|
||||||
agent_id: eor.agent_id,
|
|
||||||
server_received_at: receivedAt,
|
|
||||||
seq_no: nextSeqNo,
|
|
||||||
chain_hash: chainHash,
|
|
||||||
queue_message_id: queueMessageId,
|
|
||||||
};
|
|
||||||
|
|
||||||
const receiptHash = await computeReceiptHash(receiptFields);
|
|
||||||
|
|
||||||
// Sign the receipt hash with the server key
|
|
||||||
const receiptDataToSign = new TextEncoder().encode(receiptHash);
|
|
||||||
const elydoraSignature = await signEd25519(signingKey, receiptDataToSign);
|
|
||||||
|
|
||||||
const ear: EAR = {
|
|
||||||
...receiptFields,
|
|
||||||
receipt_hash: receiptHash,
|
|
||||||
elydora_kid: ELYDORA_KID,
|
|
||||||
elydora_signature: elydoraSignature,
|
|
||||||
};
|
|
||||||
|
|
||||||
// Store receipt in R2
|
|
||||||
const r2ReceiptKey = `${eor.org_id}/${eor.agent_id}/receipts/${eor.operation_id}`;
|
|
||||||
await r2.put(r2ReceiptKey, JSON.stringify(ear), {
|
|
||||||
httpMetadata: { contentType: 'application/json' },
|
|
||||||
});
|
|
||||||
|
|
||||||
// ------------------------------------------------------------------
|
|
||||||
// Step 12: Persist operation and receipt to D1
|
|
||||||
// ------------------------------------------------------------------
|
|
||||||
const statements: PreparedStatement[] = [];
|
|
||||||
|
|
||||||
statements.push(
|
|
||||||
db
|
|
||||||
.prepare(
|
|
||||||
`INSERT INTO operations (operation_id, org_id, agent_id, seq_no, operation_type, issued_at, ttl_ms, nonce, subject, action, payload_hash, prev_chain_hash, chain_hash, agent_pubkey_kid, signature, r2_payload_key, created_at)
|
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
|
||||||
)
|
|
||||||
.bind(
|
|
||||||
operation.operation_id,
|
|
||||||
operation.org_id,
|
|
||||||
operation.agent_id,
|
|
||||||
operation.seq_no,
|
|
||||||
operation.operation_type,
|
|
||||||
operation.issued_at,
|
|
||||||
operation.ttl_ms,
|
|
||||||
operation.nonce,
|
|
||||||
operation.subject,
|
|
||||||
operation.action,
|
|
||||||
operation.payload_hash,
|
|
||||||
operation.prev_chain_hash,
|
|
||||||
operation.chain_hash,
|
|
||||||
operation.agent_pubkey_kid,
|
|
||||||
operation.signature,
|
|
||||||
operation.r2_payload_key,
|
|
||||||
operation.created_at,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
|
|
||||||
statements.push(
|
|
||||||
db
|
|
||||||
.prepare(
|
|
||||||
`INSERT INTO receipts (receipt_id, operation_id, r2_receipt_key, created_at)
|
|
||||||
VALUES (?, ?, ?, ?)`,
|
|
||||||
)
|
|
||||||
.bind(receiptId, eor.operation_id, r2ReceiptKey, receivedAt),
|
|
||||||
);
|
|
||||||
|
|
||||||
try {
|
|
||||||
await db.batch(statements);
|
|
||||||
} catch (error) {
|
|
||||||
if (!isSeqNoConflict(error)) throw error;
|
|
||||||
const winner = await db
|
|
||||||
.prepare('SELECT chain_hash FROM operations WHERE agent_id = ? ORDER BY seq_no DESC LIMIT 1')
|
|
||||||
.bind(eor.agent_id)
|
|
||||||
.first<{ chain_hash: string }>();
|
|
||||||
if (!winner) throw new Error('seq_no conflict without a stored operation.');
|
|
||||||
throw new AppError(400, 'PREV_HASH_MISMATCH', {
|
|
||||||
key: 'operation.prevHashMismatch',
|
|
||||||
params: { expected: winner.chain_hash, actual: eor.prev_chain_hash },
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
await queue.send(queueMessageId, {
|
|
||||||
type: 'operation',
|
|
||||||
operation_id: eor.operation_id,
|
|
||||||
org_id: eor.org_id,
|
|
||||||
agent_id: eor.agent_id,
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
console.error('operation.enqueue_failed', {
|
|
||||||
operation_id: eor.operation_id,
|
|
||||||
queue_message_id: queueMessageId,
|
|
||||||
error: error instanceof Error ? error.message : String(error),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return { receipt: ear };
|
|
||||||
}
|
|
||||||
|
|
||||||
function isSeqNoConflict(error: unknown): boolean {
|
|
||||||
if (typeof error !== 'object' || error === null) return false;
|
|
||||||
const { code, constraint } = error as { code?: unknown; constraint?: unknown };
|
|
||||||
return code === '23505' && constraint === 'operations_agent_id_seq_no_key';
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Get operation
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
export async function getOperation(
|
export async function getOperation(
|
||||||
db: Database,
|
db: Database,
|
||||||
@@ -354,7 +35,7 @@ export async function getOperation(
|
|||||||
payload = (eor.payload as Record<string, unknown>) ?? undefined;
|
payload = (eor.payload as Record<string, unknown>) ?? undefined;
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
// Payload fetch is best-effort — don't fail the request
|
// Payload fetch is best-effort: don't fail the request
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -368,290 +49,3 @@ export async function getOperation(
|
|||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Verify operation
|
// Verify operation
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
export async function verifyOperation(
|
|
||||||
db: Database,
|
|
||||||
r2: ObjectStore,
|
|
||||||
operationId: string,
|
|
||||||
orgId: string,
|
|
||||||
): Promise<VerifyOperationResponse> {
|
|
||||||
const errors: string[] = [];
|
|
||||||
let signatureCheck = false;
|
|
||||||
let chainCheck = false;
|
|
||||||
let receiptCheck = false;
|
|
||||||
|
|
||||||
// Load the operation
|
|
||||||
const operation = await db
|
|
||||||
.prepare('SELECT * FROM operations WHERE operation_id = ? AND org_id = ?')
|
|
||||||
.bind(operationId, orgId)
|
|
||||||
.first<Operation>();
|
|
||||||
|
|
||||||
if (!operation) {
|
|
||||||
throw new AppError(404, 'NOT_FOUND', { key: 'operation.notFound', params: { id: operationId } });
|
|
||||||
}
|
|
||||||
|
|
||||||
// Load the EOR from R2
|
|
||||||
if (!operation.r2_payload_key) {
|
|
||||||
errors.push('Operation has no R2 payload key.');
|
|
||||||
return { valid: false, checks: { signature: false, chain: false, receipt: false }, errors };
|
|
||||||
}
|
|
||||||
const eorObject = await r2.get(operation.r2_payload_key);
|
|
||||||
if (!eorObject) {
|
|
||||||
errors.push('EOR evidence not found in R2.');
|
|
||||||
return { valid: false, checks: { signature: false, chain: false, receipt: false }, errors };
|
|
||||||
}
|
|
||||||
|
|
||||||
let eor: EOR;
|
|
||||||
try {
|
|
||||||
eor = JSON.parse(await eorObject.text()) as EOR;
|
|
||||||
} catch {
|
|
||||||
errors.push('Failed to parse EOR evidence from R2.');
|
|
||||||
return { valid: false, checks: { signature: false, chain: false, receipt: false }, errors };
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---- Signature verification ----
|
|
||||||
try {
|
|
||||||
const agentKey = await db
|
|
||||||
.prepare('SELECT * FROM agent_keys WHERE kid = ? AND agent_id = ?')
|
|
||||||
.bind(eor.agent_pubkey_kid, eor.agent_id)
|
|
||||||
.first<AgentKey>();
|
|
||||||
|
|
||||||
if (agentKey) {
|
|
||||||
const signableEOR = buildSignableEOR(eor);
|
|
||||||
const canonicalData = jcsCanonicalise(signableEOR);
|
|
||||||
const dataBytes = new TextEncoder().encode(canonicalData);
|
|
||||||
const signatureBytes = base64urlDecode(eor.signature);
|
|
||||||
const publicKeyBytes = base64urlDecode(agentKey.public_key);
|
|
||||||
|
|
||||||
signatureCheck = await verifyEd25519Signature(publicKeyBytes, signatureBytes, dataBytes);
|
|
||||||
if (!signatureCheck) {
|
|
||||||
errors.push('Ed25519 signature verification failed.');
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
errors.push(`Signing key "${eor.agent_pubkey_kid}" not found.`);
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
errors.push(`Signature check error: ${e instanceof Error ? e.message : String(e)}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---- Chain hash verification ----
|
|
||||||
try {
|
|
||||||
const expectedChainHash = await computeChainHash(
|
|
||||||
operation.prev_chain_hash,
|
|
||||||
operation.payload_hash,
|
|
||||||
operation.operation_id,
|
|
||||||
operation.issued_at,
|
|
||||||
);
|
|
||||||
|
|
||||||
chainCheck = expectedChainHash === operation.chain_hash;
|
|
||||||
if (!chainCheck) {
|
|
||||||
errors.push(`Chain hash mismatch: expected "${expectedChainHash}", stored "${operation.chain_hash}".`);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Also verify the prev_chain_hash links correctly
|
|
||||||
if (operation.seq_no > 1) {
|
|
||||||
const prevOp = await db
|
|
||||||
.prepare('SELECT chain_hash FROM operations WHERE agent_id = ? AND seq_no = ?')
|
|
||||||
.bind(operation.agent_id, operation.seq_no - 1)
|
|
||||||
.first<{ chain_hash: string }>();
|
|
||||||
|
|
||||||
if (prevOp && prevOp.chain_hash !== operation.prev_chain_hash) {
|
|
||||||
chainCheck = false;
|
|
||||||
errors.push(`prev_chain_hash does not match previous operation's chain_hash.`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
errors.push(`Chain check error: ${e instanceof Error ? e.message : String(e)}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---- Receipt verification ----
|
|
||||||
try {
|
|
||||||
const receiptRow = await db
|
|
||||||
.prepare('SELECT * FROM receipts WHERE operation_id = ?')
|
|
||||||
.bind(operationId)
|
|
||||||
.first<Receipt>();
|
|
||||||
|
|
||||||
if (receiptRow) {
|
|
||||||
const receiptObject = await r2.get(receiptRow.r2_receipt_key);
|
|
||||||
if (receiptObject) {
|
|
||||||
const ear = JSON.parse(await receiptObject.text()) as EAR;
|
|
||||||
|
|
||||||
// Recompute receipt hash
|
|
||||||
const receiptFields = {
|
|
||||||
receipt_version: ear.receipt_version,
|
|
||||||
receipt_id: ear.receipt_id,
|
|
||||||
operation_id: ear.operation_id,
|
|
||||||
org_id: ear.org_id,
|
|
||||||
agent_id: ear.agent_id,
|
|
||||||
server_received_at: ear.server_received_at,
|
|
||||||
seq_no: ear.seq_no,
|
|
||||||
chain_hash: ear.chain_hash,
|
|
||||||
queue_message_id: ear.queue_message_id,
|
|
||||||
};
|
|
||||||
|
|
||||||
const expectedHash = await computeReceiptHash(receiptFields);
|
|
||||||
if (expectedHash === ear.receipt_hash) {
|
|
||||||
receiptCheck = true;
|
|
||||||
} else {
|
|
||||||
errors.push('Receipt hash does not match recomputed value.');
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
errors.push('Receipt evidence not found in R2.');
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
errors.push('No receipt found for this operation.');
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
errors.push(`Receipt check error: ${e instanceof Error ? e.message : String(e)}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---- Merkle inclusion verification ----
|
|
||||||
let merkleCheck: boolean | undefined;
|
|
||||||
|
|
||||||
// Find epoch by operation's created_at timestamp
|
|
||||||
const epochRecord = await db
|
|
||||||
.prepare('SELECT * FROM epochs WHERE org_id = ? AND start_time <= ? AND end_time > ?')
|
|
||||||
.bind(orgId, operation.created_at, operation.created_at)
|
|
||||||
.first<{ 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 }>();
|
|
||||||
|
|
||||||
if (epochRecord) {
|
|
||||||
try {
|
|
||||||
const epochObj = await r2.get(epochRecord.r2_epoch_key);
|
|
||||||
if (epochObj) {
|
|
||||||
const epochData = JSON.parse(await epochObj.text()) as {
|
|
||||||
merkle: { leafOps: string[]; layers: string[][] };
|
|
||||||
};
|
|
||||||
// Check if operation's chain_hash is in the leaf layer
|
|
||||||
const leaves = epochData.merkle.layers[0];
|
|
||||||
if (leaves && leaves.includes(operation.chain_hash)) {
|
|
||||||
// Verify the tree root matches the epoch's root_hash
|
|
||||||
const { getMerkleProof, verifyMerkleProof } = await import('../utils/merkle.js');
|
|
||||||
const tree = {
|
|
||||||
root: epochRecord.root_hash,
|
|
||||||
leaves,
|
|
||||||
leafOps: epochData.merkle.leafOps,
|
|
||||||
layers: epochData.merkle.layers,
|
|
||||||
};
|
|
||||||
const proof = getMerkleProof(tree, operation.chain_hash);
|
|
||||||
if (proof) {
|
|
||||||
merkleCheck = await verifyMerkleProof(proof);
|
|
||||||
if (!merkleCheck) {
|
|
||||||
errors.push('Merkle inclusion proof verification failed.');
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
merkleCheck = false;
|
|
||||||
errors.push('Operation chain_hash found in leaves but proof generation failed.');
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
merkleCheck = false;
|
|
||||||
errors.push('Operation chain_hash not found in epoch Merkle tree leaves.');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
// If R2 read fails, leave merkle as undefined (pending)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// If no epoch found, merkleCheck remains undefined (pending)
|
|
||||||
|
|
||||||
const valid = signatureCheck && chainCheck && receiptCheck;
|
|
||||||
|
|
||||||
return {
|
|
||||||
valid,
|
|
||||||
checks: {
|
|
||||||
signature: signatureCheck,
|
|
||||||
chain: chainCheck,
|
|
||||||
receipt: receiptCheck,
|
|
||||||
...(merkleCheck !== undefined ? { merkle: merkleCheck } : {}),
|
|
||||||
},
|
|
||||||
errors: errors.length > 0 ? errors : undefined,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Helpers
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Build the signable portion of an EOR (excludes the signature field itself).
|
|
||||||
*/
|
|
||||||
function buildSignableEOR(eor: EOR): Record<string, unknown> {
|
|
||||||
return {
|
|
||||||
op_version: eor.op_version,
|
|
||||||
operation_id: eor.operation_id,
|
|
||||||
org_id: eor.org_id,
|
|
||||||
agent_id: eor.agent_id,
|
|
||||||
issued_at: eor.issued_at,
|
|
||||||
ttl_ms: eor.ttl_ms,
|
|
||||||
nonce: eor.nonce,
|
|
||||||
operation_type: eor.operation_type,
|
|
||||||
subject: eor.subject,
|
|
||||||
action: eor.action,
|
|
||||||
payload: eor.payload,
|
|
||||||
payload_hash: eor.payload_hash,
|
|
||||||
prev_chain_hash: eor.prev_chain_hash,
|
|
||||||
agent_pubkey_kid: eor.agent_pubkey_kid,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Validate all required EOR fields before processing.
|
|
||||||
*/
|
|
||||||
function validateEORFields(eor: EOR, receivedAt: number): void {
|
|
||||||
// Protocol version
|
|
||||||
if (eor.op_version !== '1.0') {
|
|
||||||
throw new AppError(400, 'VALIDATION_ERROR', { key: 'operation.unsupportedVersion', params: { version: eor.op_version } });
|
|
||||||
}
|
|
||||||
|
|
||||||
// Required string fields
|
|
||||||
const requiredStrings: Array<[string, unknown]> = [
|
|
||||||
['operation_id', eor.operation_id],
|
|
||||||
['org_id', eor.org_id],
|
|
||||||
['agent_id', eor.agent_id],
|
|
||||||
['nonce', eor.nonce],
|
|
||||||
['operation_type', eor.operation_type],
|
|
||||||
['payload_hash', eor.payload_hash],
|
|
||||||
['prev_chain_hash', eor.prev_chain_hash],
|
|
||||||
['agent_pubkey_kid', eor.agent_pubkey_kid],
|
|
||||||
['signature', eor.signature],
|
|
||||||
];
|
|
||||||
|
|
||||||
for (const [field, value] of requiredStrings) {
|
|
||||||
if (!value || typeof value !== 'string' || value.trim().length === 0) {
|
|
||||||
throw new AppError(400, 'VALIDATION_ERROR', { key: 'operation.missingField', params: { field } });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Nonce length
|
|
||||||
if (eor.nonce.length > MAX_NONCE_LENGTH) {
|
|
||||||
throw new AppError(400, 'VALIDATION_ERROR', { key: 'operation.nonceTooLong', params: { max: MAX_NONCE_LENGTH } });
|
|
||||||
}
|
|
||||||
|
|
||||||
// issued_at must be a number
|
|
||||||
if (typeof eor.issued_at !== 'number' || eor.issued_at <= 0) {
|
|
||||||
throw new AppError(400, 'VALIDATION_ERROR', { key: 'operation.invalidIssuedAt' });
|
|
||||||
}
|
|
||||||
|
|
||||||
// TTL bounds
|
|
||||||
if (typeof eor.ttl_ms !== 'number') {
|
|
||||||
throw new AppError(400, 'VALIDATION_ERROR', { key: 'operation.missingTtl' });
|
|
||||||
}
|
|
||||||
if (eor.ttl_ms < MIN_TTL_MS) {
|
|
||||||
throw new AppError(400, 'VALIDATION_ERROR', { key: 'operation.ttlTooLow', params: { min: MIN_TTL_MS } });
|
|
||||||
}
|
|
||||||
if (eor.ttl_ms > MAX_TTL_MS) {
|
|
||||||
throw new AppError(400, 'VALIDATION_ERROR', { key: 'operation.ttlTooHigh', params: { max: MAX_TTL_MS } });
|
|
||||||
}
|
|
||||||
|
|
||||||
// TTL expiration check
|
|
||||||
if (eor.issued_at + eor.ttl_ms < receivedAt) {
|
|
||||||
throw new AppError(400, 'TTL_EXPIRED');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Payload size check (approximate via JSON serialization)
|
|
||||||
if (eor.payload !== null && eor.payload !== undefined) {
|
|
||||||
const payloadStr = typeof eor.payload === 'string' ? eor.payload : JSON.stringify(eor.payload);
|
|
||||||
if (new TextEncoder().encode(payloadStr).byteLength > MAX_PAYLOAD_SIZE) {
|
|
||||||
throw new AppError(400, 'PAYLOAD_TOO_LARGE');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,256 @@
|
|||||||
|
import type { EOR, EAR, Agent, AgentKey, Operation } from '../shared/index.js';
|
||||||
|
import {
|
||||||
|
base64urlDecode,
|
||||||
|
verifyEd25519Signature,
|
||||||
|
jcsCanonicalise,
|
||||||
|
computeChainHash,
|
||||||
|
computeReceiptHash,
|
||||||
|
signEd25519,
|
||||||
|
} from '../utils/crypto.js';
|
||||||
|
import { generateUUIDv7 } from '../utils/uuid.js';
|
||||||
|
import { AppError } from '../middleware/error-handler.js';
|
||||||
|
import type { Database, ObjectStore, Cache, MessageQueue, PreparedStatement } from '../adapters/interfaces.js';
|
||||||
|
import { ELYDORA_KID, GENESIS_CHAIN_HASH, buildSignableEOR, validateEORFields } from './operation-eor.js';
|
||||||
|
|
||||||
|
export async function submitOperation(
|
||||||
|
db: Database,
|
||||||
|
r2: ObjectStore,
|
||||||
|
kv: Cache,
|
||||||
|
queue: MessageQueue,
|
||||||
|
eor: EOR,
|
||||||
|
signingKey: string,
|
||||||
|
): Promise<{ receipt: EAR }> {
|
||||||
|
const receivedAt = Date.now();
|
||||||
|
|
||||||
|
// Step 1: Validate EOR fields
|
||||||
|
validateEORFields(eor, receivedAt);
|
||||||
|
|
||||||
|
// Step 2: Replay detection via nonce in KV
|
||||||
|
const nonceKey = `nonce:${eor.org_id}:${eor.nonce}`;
|
||||||
|
const existingNonce = await kv.get(nonceKey);
|
||||||
|
if (existingNonce) {
|
||||||
|
throw new AppError(400, 'REPLAY_DETECTED');
|
||||||
|
}
|
||||||
|
// Store nonce with TTL equal to the operation's TTL (in seconds)
|
||||||
|
const nonceTtlSeconds = Math.ceil(eor.ttl_ms / 1000);
|
||||||
|
await kv.put(nonceKey, '1', { expirationTtl: nonceTtlSeconds });
|
||||||
|
|
||||||
|
// Step 3: Look up agent and check status
|
||||||
|
const agent = await db
|
||||||
|
.prepare('SELECT * FROM agents WHERE agent_id = ? AND org_id = ?')
|
||||||
|
.bind(eor.agent_id, eor.org_id)
|
||||||
|
.first<Agent>();
|
||||||
|
|
||||||
|
if (!agent) {
|
||||||
|
throw new AppError(404, 'UNKNOWN_AGENT', { key: 'operation.agentNotRegistered', params: { id: eor.agent_id } });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (agent.status === 'frozen') {
|
||||||
|
throw new AppError(403, 'AGENT_FROZEN');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (agent.status === 'revoked') {
|
||||||
|
throw new AppError(403, 'AGENT_REVOKED', { key: 'operation.agentRevoked' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 4: Look up key and check status
|
||||||
|
const agentKey = await db
|
||||||
|
.prepare('SELECT * FROM agent_keys WHERE kid = ? AND agent_id = ?')
|
||||||
|
.bind(eor.agent_pubkey_kid, eor.agent_id)
|
||||||
|
.first<AgentKey>();
|
||||||
|
|
||||||
|
if (!agentKey) {
|
||||||
|
throw new AppError(400, 'INVALID_SIGNATURE', { key: 'operation.keyNotFoundForAgent', params: { kid: eor.agent_pubkey_kid, id: eor.agent_id } });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (agentKey.status === 'revoked') {
|
||||||
|
throw new AppError(403, 'KEY_REVOKED');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (agentKey.status === 'retired') {
|
||||||
|
throw new AppError(403, 'KEY_RETIRED', { key: 'operation.keyRetired' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 5: Verify Ed25519 signature
|
||||||
|
const signableEOR = buildSignableEOR(eor);
|
||||||
|
const canonicalData = jcsCanonicalise(signableEOR);
|
||||||
|
const dataBytes = new TextEncoder().encode(canonicalData);
|
||||||
|
const signatureBytes = base64urlDecode(eor.signature);
|
||||||
|
const publicKeyBytes = base64urlDecode(agentKey.public_key);
|
||||||
|
|
||||||
|
const signatureValid = await verifyEd25519Signature(publicKeyBytes, signatureBytes, dataBytes);
|
||||||
|
|
||||||
|
if (!signatureValid) {
|
||||||
|
throw new AppError(400, 'INVALID_SIGNATURE');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 6: Verify prev_chain_hash against the latest chain_hash
|
||||||
|
const latestOp = await db
|
||||||
|
.prepare(
|
||||||
|
'SELECT chain_hash, seq_no FROM operations WHERE agent_id = ? ORDER BY seq_no DESC LIMIT 1',
|
||||||
|
)
|
||||||
|
.bind(eor.agent_id)
|
||||||
|
.first<{ chain_hash: string; seq_no: number }>();
|
||||||
|
|
||||||
|
const expectedPrevHash = latestOp ? latestOp.chain_hash : GENESIS_CHAIN_HASH;
|
||||||
|
const nextSeqNo = latestOp ? latestOp.seq_no + 1 : 1;
|
||||||
|
|
||||||
|
if (eor.prev_chain_hash !== expectedPrevHash) {
|
||||||
|
throw new AppError(400, 'PREV_HASH_MISMATCH', { key: 'operation.prevHashMismatch', params: { expected: expectedPrevHash, actual: eor.prev_chain_hash } });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 7: Compute new chain_hash
|
||||||
|
const chainHash = await computeChainHash(
|
||||||
|
eor.prev_chain_hash,
|
||||||
|
eor.payload_hash,
|
||||||
|
eor.operation_id,
|
||||||
|
eor.issued_at,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Step 8: Store operation in D1
|
||||||
|
const r2PayloadKey = `${eor.org_id}/${eor.agent_id}/${eor.operation_id}`;
|
||||||
|
|
||||||
|
const operation: Operation = {
|
||||||
|
operation_id: eor.operation_id,
|
||||||
|
org_id: eor.org_id,
|
||||||
|
agent_id: eor.agent_id,
|
||||||
|
seq_no: nextSeqNo,
|
||||||
|
operation_type: eor.operation_type,
|
||||||
|
issued_at: eor.issued_at,
|
||||||
|
ttl_ms: eor.ttl_ms,
|
||||||
|
nonce: eor.nonce,
|
||||||
|
subject: JSON.stringify(eor.subject),
|
||||||
|
action: JSON.stringify(eor.action),
|
||||||
|
payload_hash: eor.payload_hash,
|
||||||
|
prev_chain_hash: eor.prev_chain_hash,
|
||||||
|
chain_hash: chainHash,
|
||||||
|
agent_pubkey_kid: eor.agent_pubkey_kid,
|
||||||
|
signature: eor.signature,
|
||||||
|
r2_payload_key: r2PayloadKey,
|
||||||
|
created_at: receivedAt,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Step 9: Store the full EOR envelope in R2
|
||||||
|
const eorJson = JSON.stringify(eor);
|
||||||
|
await r2.put(r2PayloadKey, eorJson, {
|
||||||
|
httpMetadata: { contentType: 'application/json' },
|
||||||
|
customMetadata: {
|
||||||
|
org_id: eor.org_id,
|
||||||
|
agent_id: eor.agent_id,
|
||||||
|
operation_type: eor.operation_type,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Step 10: Reserve the queue message ID; the send follows the commit
|
||||||
|
const queueMessageId = generateUUIDv7();
|
||||||
|
const receiptId = generateUUIDv7();
|
||||||
|
|
||||||
|
// Step 11: Generate EAR receipt
|
||||||
|
const receiptFields = {
|
||||||
|
receipt_version: '1.0',
|
||||||
|
receipt_id: receiptId,
|
||||||
|
operation_id: eor.operation_id,
|
||||||
|
org_id: eor.org_id,
|
||||||
|
agent_id: eor.agent_id,
|
||||||
|
server_received_at: receivedAt,
|
||||||
|
seq_no: nextSeqNo,
|
||||||
|
chain_hash: chainHash,
|
||||||
|
queue_message_id: queueMessageId,
|
||||||
|
};
|
||||||
|
|
||||||
|
const receiptHash = await computeReceiptHash(receiptFields);
|
||||||
|
|
||||||
|
// Sign the receipt hash with the server key
|
||||||
|
const receiptDataToSign = new TextEncoder().encode(receiptHash);
|
||||||
|
const elydoraSignature = await signEd25519(signingKey, receiptDataToSign);
|
||||||
|
|
||||||
|
const ear: EAR = {
|
||||||
|
...receiptFields,
|
||||||
|
receipt_hash: receiptHash,
|
||||||
|
elydora_kid: ELYDORA_KID,
|
||||||
|
elydora_signature: elydoraSignature,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Store receipt in R2
|
||||||
|
const r2ReceiptKey = `${eor.org_id}/${eor.agent_id}/receipts/${eor.operation_id}`;
|
||||||
|
await r2.put(r2ReceiptKey, JSON.stringify(ear), {
|
||||||
|
httpMetadata: { contentType: 'application/json' },
|
||||||
|
});
|
||||||
|
|
||||||
|
// Step 12: Persist operation and receipt to D1
|
||||||
|
const statements: PreparedStatement[] = [];
|
||||||
|
|
||||||
|
statements.push(
|
||||||
|
db
|
||||||
|
.prepare(
|
||||||
|
`INSERT INTO operations (operation_id, org_id, agent_id, seq_no, operation_type, issued_at, ttl_ms, nonce, subject, action, payload_hash, prev_chain_hash, chain_hash, agent_pubkey_kid, signature, r2_payload_key, created_at)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||||
|
)
|
||||||
|
.bind(
|
||||||
|
operation.operation_id,
|
||||||
|
operation.org_id,
|
||||||
|
operation.agent_id,
|
||||||
|
operation.seq_no,
|
||||||
|
operation.operation_type,
|
||||||
|
operation.issued_at,
|
||||||
|
operation.ttl_ms,
|
||||||
|
operation.nonce,
|
||||||
|
operation.subject,
|
||||||
|
operation.action,
|
||||||
|
operation.payload_hash,
|
||||||
|
operation.prev_chain_hash,
|
||||||
|
operation.chain_hash,
|
||||||
|
operation.agent_pubkey_kid,
|
||||||
|
operation.signature,
|
||||||
|
operation.r2_payload_key,
|
||||||
|
operation.created_at,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
statements.push(
|
||||||
|
db
|
||||||
|
.prepare(
|
||||||
|
`INSERT INTO receipts (receipt_id, operation_id, r2_receipt_key, created_at)
|
||||||
|
VALUES (?, ?, ?, ?)`,
|
||||||
|
)
|
||||||
|
.bind(receiptId, eor.operation_id, r2ReceiptKey, receivedAt),
|
||||||
|
);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await db.batch(statements);
|
||||||
|
} catch (error) {
|
||||||
|
if (!isSeqNoConflict(error)) throw error;
|
||||||
|
const winner = await db
|
||||||
|
.prepare('SELECT chain_hash FROM operations WHERE agent_id = ? ORDER BY seq_no DESC LIMIT 1')
|
||||||
|
.bind(eor.agent_id)
|
||||||
|
.first<{ chain_hash: string }>();
|
||||||
|
if (!winner) throw new Error('seq_no conflict without a stored operation.');
|
||||||
|
throw new AppError(400, 'PREV_HASH_MISMATCH', {
|
||||||
|
key: 'operation.prevHashMismatch',
|
||||||
|
params: { expected: winner.chain_hash, actual: eor.prev_chain_hash },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await queue.send(queueMessageId, {
|
||||||
|
type: 'operation',
|
||||||
|
operation_id: eor.operation_id,
|
||||||
|
org_id: eor.org_id,
|
||||||
|
agent_id: eor.agent_id,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('operation.enqueue_failed', {
|
||||||
|
operation_id: eor.operation_id,
|
||||||
|
queue_message_id: queueMessageId,
|
||||||
|
error: error instanceof Error ? error.message : String(error),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return { receipt: ear };
|
||||||
|
}
|
||||||
|
|
||||||
|
function isSeqNoConflict(error: unknown): boolean {
|
||||||
|
if (typeof error !== 'object' || error === null) return false;
|
||||||
|
const { code, constraint } = error as { code?: unknown; constraint?: unknown };
|
||||||
|
return code === '23505' && constraint === 'operations_agent_id_seq_no_key';
|
||||||
|
}
|
||||||
@@ -0,0 +1,209 @@
|
|||||||
|
import type { EOR, EAR, AgentKey, Operation, Receipt, VerifyOperationResponse } from '../shared/index.js';
|
||||||
|
import {
|
||||||
|
base64urlDecode,
|
||||||
|
verifyEd25519Signature,
|
||||||
|
jcsCanonicalise,
|
||||||
|
computeChainHash,
|
||||||
|
computeReceiptHash,
|
||||||
|
} from '../utils/crypto.js';
|
||||||
|
import { AppError } from '../middleware/error-handler.js';
|
||||||
|
import { getMerkleProof, verifyMerkleProof } from '../utils/merkle.js';
|
||||||
|
import type { Database, ObjectStore } from '../adapters/interfaces.js';
|
||||||
|
import { buildSignableEOR } from './operation-eor.js';
|
||||||
|
|
||||||
|
export async function verifyOperation(
|
||||||
|
db: Database,
|
||||||
|
r2: ObjectStore,
|
||||||
|
operationId: string,
|
||||||
|
orgId: string,
|
||||||
|
): Promise<VerifyOperationResponse> {
|
||||||
|
const errors: string[] = [];
|
||||||
|
let signatureCheck = false;
|
||||||
|
let chainCheck = false;
|
||||||
|
let receiptCheck = false;
|
||||||
|
|
||||||
|
// Load the operation
|
||||||
|
const operation = await db
|
||||||
|
.prepare('SELECT * FROM operations WHERE operation_id = ? AND org_id = ?')
|
||||||
|
.bind(operationId, orgId)
|
||||||
|
.first<Operation>();
|
||||||
|
|
||||||
|
if (!operation) {
|
||||||
|
throw new AppError(404, 'NOT_FOUND', { key: 'operation.notFound', params: { id: operationId } });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load the EOR from R2
|
||||||
|
if (!operation.r2_payload_key) {
|
||||||
|
errors.push('Operation has no R2 payload key.');
|
||||||
|
return { valid: false, checks: { signature: false, chain: false, receipt: false }, errors };
|
||||||
|
}
|
||||||
|
const eorObject = await r2.get(operation.r2_payload_key);
|
||||||
|
if (!eorObject) {
|
||||||
|
errors.push('EOR evidence not found in R2.');
|
||||||
|
return { valid: false, checks: { signature: false, chain: false, receipt: false }, errors };
|
||||||
|
}
|
||||||
|
|
||||||
|
let eor: EOR;
|
||||||
|
try {
|
||||||
|
eor = JSON.parse(await eorObject.text()) as EOR;
|
||||||
|
} catch {
|
||||||
|
errors.push('Failed to parse EOR evidence from R2.');
|
||||||
|
return { valid: false, checks: { signature: false, chain: false, receipt: false }, errors };
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Signature verification ----
|
||||||
|
try {
|
||||||
|
const agentKey = await db
|
||||||
|
.prepare('SELECT * FROM agent_keys WHERE kid = ? AND agent_id = ?')
|
||||||
|
.bind(eor.agent_pubkey_kid, eor.agent_id)
|
||||||
|
.first<AgentKey>();
|
||||||
|
|
||||||
|
if (agentKey) {
|
||||||
|
const signableEOR = buildSignableEOR(eor);
|
||||||
|
const canonicalData = jcsCanonicalise(signableEOR);
|
||||||
|
const dataBytes = new TextEncoder().encode(canonicalData);
|
||||||
|
const signatureBytes = base64urlDecode(eor.signature);
|
||||||
|
const publicKeyBytes = base64urlDecode(agentKey.public_key);
|
||||||
|
|
||||||
|
signatureCheck = await verifyEd25519Signature(publicKeyBytes, signatureBytes, dataBytes);
|
||||||
|
if (!signatureCheck) {
|
||||||
|
errors.push('Ed25519 signature verification failed.');
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
errors.push(`Signing key "${eor.agent_pubkey_kid}" not found.`);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
errors.push(`Signature check error: ${e instanceof Error ? e.message : String(e)}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Chain hash verification ----
|
||||||
|
try {
|
||||||
|
const expectedChainHash = await computeChainHash(
|
||||||
|
operation.prev_chain_hash,
|
||||||
|
operation.payload_hash,
|
||||||
|
operation.operation_id,
|
||||||
|
operation.issued_at,
|
||||||
|
);
|
||||||
|
|
||||||
|
chainCheck = expectedChainHash === operation.chain_hash;
|
||||||
|
if (!chainCheck) {
|
||||||
|
errors.push(`Chain hash mismatch: expected "${expectedChainHash}", stored "${operation.chain_hash}".`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Also verify the prev_chain_hash links correctly
|
||||||
|
if (operation.seq_no > 1) {
|
||||||
|
const prevOp = await db
|
||||||
|
.prepare('SELECT chain_hash FROM operations WHERE agent_id = ? AND seq_no = ?')
|
||||||
|
.bind(operation.agent_id, operation.seq_no - 1)
|
||||||
|
.first<{ chain_hash: string }>();
|
||||||
|
|
||||||
|
if (prevOp && prevOp.chain_hash !== operation.prev_chain_hash) {
|
||||||
|
chainCheck = false;
|
||||||
|
errors.push(`prev_chain_hash does not match previous operation's chain_hash.`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
errors.push(`Chain check error: ${e instanceof Error ? e.message : String(e)}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Receipt verification ----
|
||||||
|
try {
|
||||||
|
const receiptRow = await db
|
||||||
|
.prepare('SELECT * FROM receipts WHERE operation_id = ?')
|
||||||
|
.bind(operationId)
|
||||||
|
.first<Receipt>();
|
||||||
|
|
||||||
|
if (receiptRow) {
|
||||||
|
const receiptObject = await r2.get(receiptRow.r2_receipt_key);
|
||||||
|
if (receiptObject) {
|
||||||
|
const ear = JSON.parse(await receiptObject.text()) as EAR;
|
||||||
|
|
||||||
|
// Recompute receipt hash
|
||||||
|
const receiptFields = {
|
||||||
|
receipt_version: ear.receipt_version,
|
||||||
|
receipt_id: ear.receipt_id,
|
||||||
|
operation_id: ear.operation_id,
|
||||||
|
org_id: ear.org_id,
|
||||||
|
agent_id: ear.agent_id,
|
||||||
|
server_received_at: ear.server_received_at,
|
||||||
|
seq_no: ear.seq_no,
|
||||||
|
chain_hash: ear.chain_hash,
|
||||||
|
queue_message_id: ear.queue_message_id,
|
||||||
|
};
|
||||||
|
|
||||||
|
const expectedHash = await computeReceiptHash(receiptFields);
|
||||||
|
if (expectedHash === ear.receipt_hash) {
|
||||||
|
receiptCheck = true;
|
||||||
|
} else {
|
||||||
|
errors.push('Receipt hash does not match recomputed value.');
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
errors.push('Receipt evidence not found in R2.');
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
errors.push('No receipt found for this operation.');
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
errors.push(`Receipt check error: ${e instanceof Error ? e.message : String(e)}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Merkle inclusion verification ----
|
||||||
|
let merkleCheck: boolean | undefined;
|
||||||
|
|
||||||
|
// Find epoch by operation's created_at timestamp
|
||||||
|
const epochRecord = await db
|
||||||
|
.prepare('SELECT * FROM epochs WHERE org_id = ? AND start_time <= ? AND end_time > ?')
|
||||||
|
.bind(orgId, operation.created_at, operation.created_at)
|
||||||
|
.first<{ 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 }>();
|
||||||
|
|
||||||
|
if (epochRecord) {
|
||||||
|
try {
|
||||||
|
const epochObj = await r2.get(epochRecord.r2_epoch_key);
|
||||||
|
if (epochObj) {
|
||||||
|
const epochData = JSON.parse(await epochObj.text()) as {
|
||||||
|
merkle: { leafOps: string[]; layers: string[][] };
|
||||||
|
};
|
||||||
|
// Check if operation's chain_hash is in the leaf layer
|
||||||
|
const leaves = epochData.merkle.layers[0];
|
||||||
|
if (leaves && leaves.includes(operation.chain_hash)) {
|
||||||
|
// Verify the tree root matches the epoch's root_hash
|
||||||
|
const tree = {
|
||||||
|
root: epochRecord.root_hash,
|
||||||
|
leaves,
|
||||||
|
leafOps: epochData.merkle.leafOps,
|
||||||
|
layers: epochData.merkle.layers,
|
||||||
|
};
|
||||||
|
const proof = getMerkleProof(tree, operation.chain_hash);
|
||||||
|
if (proof) {
|
||||||
|
merkleCheck = await verifyMerkleProof(proof);
|
||||||
|
if (!merkleCheck) {
|
||||||
|
errors.push('Merkle inclusion proof verification failed.');
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
merkleCheck = false;
|
||||||
|
errors.push('Operation chain_hash found in leaves but proof generation failed.');
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
merkleCheck = false;
|
||||||
|
errors.push('Operation chain_hash not found in epoch Merkle tree leaves.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// If R2 read fails, leave merkle as undefined (pending)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// If no epoch found, merkleCheck remains undefined (pending)
|
||||||
|
|
||||||
|
const valid = signatureCheck && chainCheck && receiptCheck;
|
||||||
|
|
||||||
|
return {
|
||||||
|
valid,
|
||||||
|
checks: {
|
||||||
|
signature: signatureCheck,
|
||||||
|
chain: chainCheck,
|
||||||
|
receipt: receiptCheck,
|
||||||
|
...(merkleCheck !== undefined ? { merkle: merkleCheck } : {}),
|
||||||
|
},
|
||||||
|
errors: errors.length > 0 ? errors : undefined,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -5,7 +5,9 @@ export const ERROR_CODES: Readonly<Record<ErrorCode, string>> = {
|
|||||||
INVALID_SIGNATURE: 'The operation signature is invalid.',
|
INVALID_SIGNATURE: 'The operation signature is invalid.',
|
||||||
UNKNOWN_AGENT: 'The specified agent does not exist.',
|
UNKNOWN_AGENT: 'The specified agent does not exist.',
|
||||||
KEY_REVOKED: 'The signing key has been revoked.',
|
KEY_REVOKED: 'The signing key has been revoked.',
|
||||||
|
KEY_RETIRED: 'The signing key has been retired.',
|
||||||
AGENT_FROZEN: 'The agent is frozen and cannot submit operations.',
|
AGENT_FROZEN: 'The agent is frozen and cannot submit operations.',
|
||||||
|
AGENT_REVOKED: 'The agent has been revoked.',
|
||||||
TTL_EXPIRED: 'The operation TTL has expired.',
|
TTL_EXPIRED: 'The operation TTL has expired.',
|
||||||
REPLAY_DETECTED: 'A duplicate nonce was detected (replay attack).',
|
REPLAY_DETECTED: 'A duplicate nonce was detected (replay attack).',
|
||||||
PREV_HASH_MISMATCH: 'The previous chain hash does not match.',
|
PREV_HASH_MISMATCH: 'The previous chain hash does not match.',
|
||||||
|
|||||||
@@ -56,7 +56,9 @@ export type ErrorCode =
|
|||||||
| 'INVALID_SIGNATURE'
|
| 'INVALID_SIGNATURE'
|
||||||
| 'UNKNOWN_AGENT'
|
| 'UNKNOWN_AGENT'
|
||||||
| 'KEY_REVOKED'
|
| 'KEY_REVOKED'
|
||||||
|
| 'KEY_RETIRED'
|
||||||
| 'AGENT_FROZEN'
|
| 'AGENT_FROZEN'
|
||||||
|
| 'AGENT_REVOKED'
|
||||||
| 'TTL_EXPIRED'
|
| 'TTL_EXPIRED'
|
||||||
| 'REPLAY_DETECTED'
|
| 'REPLAY_DETECTED'
|
||||||
| 'PREV_HASH_MISMATCH'
|
| 'PREV_HASH_MISMATCH'
|
||||||
|
|||||||
@@ -1,9 +1,4 @@
|
|||||||
/**
|
/** Elydora Operation Record (EOR) The fundamental unit of auditable activity in the Elydora protocol. */
|
||||||
* Elydora Operation Record (EOR)
|
|
||||||
*
|
|
||||||
* The fundamental unit of auditable activity in the Elydora protocol.
|
|
||||||
* Signed by the agent's Ed25519 key and chained via prev_chain_hash.
|
|
||||||
*/
|
|
||||||
export interface EOR {
|
export interface EOR {
|
||||||
/** Protocol version, always "1.0" */
|
/** Protocol version, always "1.0" */
|
||||||
readonly op_version: '1.0';
|
readonly op_version: '1.0';
|
||||||
@@ -37,11 +32,7 @@ export interface EOR {
|
|||||||
readonly signature: string;
|
readonly signature: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/** Elydora Chain Hash (ECH) Represents the chain hash computation linking operations together. */
|
||||||
* Elydora Chain Hash (ECH)
|
|
||||||
*
|
|
||||||
* Represents the chain hash computation linking operations together.
|
|
||||||
*/
|
|
||||||
export interface ECH {
|
export interface ECH {
|
||||||
/** Previous chain hash (base64url) */
|
/** Previous chain hash (base64url) */
|
||||||
readonly prev_ech: string;
|
readonly prev_ech: string;
|
||||||
@@ -55,11 +46,7 @@ export interface ECH {
|
|||||||
readonly chain_hash: string;
|
readonly chain_hash: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/** Elydora Acknowledgment Receipt (EAR) Server-issued receipt confirming acceptance of an operation. */
|
||||||
* Elydora Acknowledgment Receipt (EAR)
|
|
||||||
*
|
|
||||||
* Server-issued receipt confirming acceptance of an operation.
|
|
||||||
*/
|
|
||||||
export interface EAR {
|
export interface EAR {
|
||||||
/** Receipt version */
|
/** Receipt version */
|
||||||
readonly receipt_version: string;
|
readonly receipt_version: string;
|
||||||
@@ -87,11 +74,7 @@ export interface EAR {
|
|||||||
readonly elydora_signature: string;
|
readonly elydora_signature: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/** Elydora Epoch Record (EER) Periodic Merkle-tree rollup anchoring operations to a root hash. */
|
||||||
* Elydora Epoch Record (EER)
|
|
||||||
*
|
|
||||||
* Periodic Merkle-tree rollup anchoring operations to a root hash.
|
|
||||||
*/
|
|
||||||
export interface EER {
|
export interface EER {
|
||||||
/** Unique epoch identifier */
|
/** Unique epoch identifier */
|
||||||
readonly epoch_id: string;
|
readonly epoch_id: string;
|
||||||
|
|||||||
@@ -1,10 +1,4 @@
|
|||||||
/**
|
/** Environment bindings for the ElydoraOpenSource Node.js server. */
|
||||||
* Environment bindings for the ElydoraOpenSource Node.js server.
|
|
||||||
*
|
|
||||||
* Replaces Cloudflare-specific bindings (D1Database, R2Bucket, KVNamespace,
|
|
||||||
* Queue) with adapter interfaces backed by PostgreSQL, MinIO, Redis, and
|
|
||||||
* BullMQ respectively.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import type { Database, ObjectStore, Cache, MessageQueue } from './adapters/interfaces.js';
|
import type { Database, ObjectStore, Cache, MessageQueue } from './adapters/interfaces.js';
|
||||||
|
|
||||||
@@ -46,9 +40,7 @@ export interface Env {
|
|||||||
TSA_URL?: string;
|
TSA_URL?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/** Variables set by middleware and available via `c.get(...)` / `c.var`. */
|
||||||
* Variables set by middleware and available via `c.get(...)` / `c.var`.
|
|
||||||
*/
|
|
||||||
export interface AppVariables {
|
export interface AppVariables {
|
||||||
/** UUIDv7 request identifier injected by the request-id middleware */
|
/** UUIDv7 request identifier injected by the request-id middleware */
|
||||||
request_id: string;
|
request_id: string;
|
||||||
@@ -62,6 +54,9 @@ export interface AppVariables {
|
|||||||
/** Actor identifier extracted from the verified session */
|
/** Actor identifier extracted from the verified session */
|
||||||
actor: string;
|
actor: string;
|
||||||
|
|
||||||
|
/** Credential kind that authenticated the request */
|
||||||
|
auth_token_type: 'api' | 'session';
|
||||||
|
|
||||||
/** Detected language from Accept-Language header (set by i18n middleware) */
|
/** Detected language from Accept-Language header (set by i18n middleware) */
|
||||||
lang: import('./i18n/messages.js').Lang;
|
lang: import('./i18n/messages.js').Lang;
|
||||||
}
|
}
|
||||||
@@ -1,9 +1,4 @@
|
|||||||
/**
|
/** Cryptographic utilities for the Elydora API. */
|
||||||
* Cryptographic utilities for the Elydora API.
|
|
||||||
*
|
|
||||||
* All operations use the Web Crypto API (SubtleCrypto) which is available
|
|
||||||
* natively in Node.js 20+ with full Ed25519 support.
|
|
||||||
*/
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Base64url helpers (RFC 4648 section 5)
|
// Base64url helpers (RFC 4648 section 5)
|
||||||
@@ -52,11 +47,7 @@ export async function sha256Base64url(data: BufferSource | string): Promise<stri
|
|||||||
// Ed25519 signature verification
|
// Ed25519 signature verification
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
/**
|
/** Import a raw Ed25519 public key (32 bytes) as a CryptoKey. */
|
||||||
* Import a raw Ed25519 public key (32 bytes) as a CryptoKey.
|
|
||||||
*
|
|
||||||
* @param publicKeyBytes - 32-byte raw Ed25519 public key
|
|
||||||
*/
|
|
||||||
export async function importEd25519PublicKey(publicKeyBytes: Uint8Array): Promise<CryptoKey> {
|
export async function importEd25519PublicKey(publicKeyBytes: Uint8Array): Promise<CryptoKey> {
|
||||||
return crypto.subtle.importKey(
|
return crypto.subtle.importKey(
|
||||||
'raw',
|
'raw',
|
||||||
@@ -67,14 +58,7 @@ export async function importEd25519PublicKey(publicKeyBytes: Uint8Array): Promis
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/** Verify an Ed25519 signature over data. */
|
||||||
* Verify an Ed25519 signature over data.
|
|
||||||
*
|
|
||||||
* @param publicKeyBytes - 32-byte raw public key
|
|
||||||
* @param signatureBytes - 64-byte Ed25519 signature
|
|
||||||
* @param data - The signed data
|
|
||||||
* @returns true if the signature is valid
|
|
||||||
*/
|
|
||||||
export async function verifyEd25519Signature(
|
export async function verifyEd25519Signature(
|
||||||
publicKeyBytes: Uint8Array,
|
publicKeyBytes: Uint8Array,
|
||||||
signatureBytes: Uint8Array,
|
signatureBytes: Uint8Array,
|
||||||
@@ -97,15 +81,7 @@ export async function verifyEd25519Signature(
|
|||||||
// Ed25519 signing (server-side receipt signing)
|
// Ed25519 signing (server-side receipt signing)
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
/**
|
/** PKCS8 DER prefix for wrapping a 32-byte Ed25519 seed into a valid PKCS8 key. */
|
||||||
* PKCS8 DER prefix for wrapping a 32-byte Ed25519 seed into a valid PKCS8 key.
|
|
||||||
*
|
|
||||||
* SEQUENCE {
|
|
||||||
* INTEGER 0
|
|
||||||
* SEQUENCE { OID 1.3.101.112 }
|
|
||||||
* OCTET STRING { OCTET STRING { <32 bytes> } }
|
|
||||||
* }
|
|
||||||
*/
|
|
||||||
const ED25519_PKCS8_PREFIX = new Uint8Array([
|
const ED25519_PKCS8_PREFIX = new Uint8Array([
|
||||||
0x30, 0x2e, // SEQUENCE (46 bytes)
|
0x30, 0x2e, // SEQUENCE (46 bytes)
|
||||||
0x02, 0x01, 0x00, // INTEGER 0
|
0x02, 0x01, 0x00, // INTEGER 0
|
||||||
@@ -123,14 +99,7 @@ function wrapSeedAsPkcs8(seed: Uint8Array): Uint8Array {
|
|||||||
return pkcs8;
|
return pkcs8;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/** Import a raw Ed25519 private key for signing. */
|
||||||
* Import a raw Ed25519 private key for signing.
|
|
||||||
*
|
|
||||||
* Node.js expects PKCS8 format for Ed25519 private keys.
|
|
||||||
* We wrap the 32-byte seed in a minimal PKCS8 envelope.
|
|
||||||
*
|
|
||||||
* @param privateKeyBase64url - base64url-encoded 32-byte Ed25519 private key seed
|
|
||||||
*/
|
|
||||||
export async function importEd25519PrivateKey(privateKeyBase64url: string): Promise<CryptoKey> {
|
export async function importEd25519PrivateKey(privateKeyBase64url: string): Promise<CryptoKey> {
|
||||||
const seed = base64urlDecode(privateKeyBase64url);
|
const seed = base64urlDecode(privateKeyBase64url);
|
||||||
const pkcs8 = wrapSeedAsPkcs8(seed);
|
const pkcs8 = wrapSeedAsPkcs8(seed);
|
||||||
@@ -144,13 +113,7 @@ export async function importEd25519PrivateKey(privateKeyBase64url: string): Prom
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/** Sign data with an Ed25519 private key. */
|
||||||
* Sign data with an Ed25519 private key.
|
|
||||||
*
|
|
||||||
* @param privateKeyBase64url - base64url-encoded 32-byte private key seed
|
|
||||||
* @param data - data to sign
|
|
||||||
* @returns base64url-encoded 64-byte signature
|
|
||||||
*/
|
|
||||||
export async function signEd25519(
|
export async function signEd25519(
|
||||||
privateKeyBase64url: string,
|
privateKeyBase64url: string,
|
||||||
data: Uint8Array,
|
data: Uint8Array,
|
||||||
@@ -164,12 +127,7 @@ export async function signEd25519(
|
|||||||
return base64urlEncode(new Uint8Array(signature));
|
return base64urlEncode(new Uint8Array(signature));
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/** Export the public key from an Ed25519 private key (for JWKS). */
|
||||||
* Export the public key from an Ed25519 private key (for JWKS).
|
|
||||||
*
|
|
||||||
* @param privateKeyBase64url - base64url-encoded 32-byte private key seed
|
|
||||||
* @returns base64url-encoded 32-byte public key
|
|
||||||
*/
|
|
||||||
export async function deriveEd25519PublicKey(privateKeyBase64url: string): Promise<string> {
|
export async function deriveEd25519PublicKey(privateKeyBase64url: string): Promise<string> {
|
||||||
const seed = base64urlDecode(privateKeyBase64url);
|
const seed = base64urlDecode(privateKeyBase64url);
|
||||||
const pkcs8 = wrapSeedAsPkcs8(seed);
|
const pkcs8 = wrapSeedAsPkcs8(seed);
|
||||||
@@ -178,7 +136,7 @@ export async function deriveEd25519PublicKey(privateKeyBase64url: string): Promi
|
|||||||
'pkcs8',
|
'pkcs8',
|
||||||
pkcs8 as unknown as Uint8Array<ArrayBuffer>,
|
pkcs8 as unknown as Uint8Array<ArrayBuffer>,
|
||||||
{ name: 'Ed25519' },
|
{ name: 'Ed25519' },
|
||||||
true, // extractable — needed to export the public key
|
true, // extractable: needed to export the public key
|
||||||
['sign'],
|
['sign'],
|
||||||
);
|
);
|
||||||
// Export as JWK to get the public 'x' parameter
|
// Export as JWK to get the public 'x' parameter
|
||||||
@@ -191,15 +149,7 @@ export async function deriveEd25519PublicKey(privateKeyBase64url: string): Promi
|
|||||||
// JCS (JSON Canonicalization Scheme - RFC 8785)
|
// JCS (JSON Canonicalization Scheme - RFC 8785)
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
/**
|
/** Canonicalize a JSON value according to JCS (RFC 8785). */
|
||||||
* Canonicalize a JSON value according to JCS (RFC 8785).
|
|
||||||
*
|
|
||||||
* JCS specifies:
|
|
||||||
* - Object keys sorted lexicographically by UTF-16 code units
|
|
||||||
* - No whitespace
|
|
||||||
* - Numbers serialized using ES2015 Number serialization
|
|
||||||
* - Strings serialized with minimal escaping per JSON spec
|
|
||||||
*/
|
|
||||||
export function jcsCanonicalise(value: unknown): string {
|
export function jcsCanonicalise(value: unknown): string {
|
||||||
if (value === null || value === undefined) {
|
if (value === null || value === undefined) {
|
||||||
return 'null';
|
return 'null';
|
||||||
@@ -237,13 +187,7 @@ export function jcsCanonicalise(value: unknown): string {
|
|||||||
// Chain hash computation
|
// Chain hash computation
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
/**
|
/** Compute the chain hash for an operation. */
|
||||||
* Compute the chain hash for an operation.
|
|
||||||
*
|
|
||||||
* chain_hash = SHA-256(prev_chain_hash + payload_hash + operation_id + issued_at)
|
|
||||||
*
|
|
||||||
* All inputs are concatenated as UTF-8 strings separated by '|'.
|
|
||||||
*/
|
|
||||||
export async function computeChainHash(
|
export async function computeChainHash(
|
||||||
prevChainHash: string,
|
prevChainHash: string,
|
||||||
payloadHash: string,
|
payloadHash: string,
|
||||||
@@ -258,11 +202,7 @@ export async function computeChainHash(
|
|||||||
// Receipt hash computation
|
// Receipt hash computation
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
/**
|
/** Compute the hash of an EAR receipt for signing. */
|
||||||
* Compute the hash of an EAR receipt for signing.
|
|
||||||
*
|
|
||||||
* Hashes the canonicalized receipt fields (excluding the signature fields themselves).
|
|
||||||
*/
|
|
||||||
export async function computeReceiptHash(receiptFields: {
|
export async function computeReceiptHash(receiptFields: {
|
||||||
receipt_version: string;
|
receipt_version: string;
|
||||||
receipt_id: string;
|
receipt_id: string;
|
||||||
|
|||||||
@@ -1,10 +1,4 @@
|
|||||||
/**
|
/** SHA-256 Merkle tree implementation for epoch rollups. */
|
||||||
* SHA-256 Merkle tree implementation for epoch rollups.
|
|
||||||
*
|
|
||||||
* Leaf hashes are sorted lexicographically before tree construction.
|
|
||||||
* Internal nodes are computed by concatenating the raw bytes (not base64url
|
|
||||||
* strings) of their children and hashing the result with SHA-256.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { sha256Base64url, base64urlDecode } from './crypto.js';
|
import { sha256Base64url, base64urlDecode } from './crypto.js';
|
||||||
|
|
||||||
@@ -38,10 +32,7 @@ export interface MerkleProof {
|
|||||||
// Internal helper
|
// Internal helper
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
/**
|
/** Compute the parent hash from two child hashes. */
|
||||||
* Compute the parent hash from two child hashes.
|
|
||||||
* Decodes both base64url hashes to raw bytes, concatenates, then SHA-256.
|
|
||||||
*/
|
|
||||||
async function hashPair(left: string, right: string): Promise<string> {
|
async function hashPair(left: string, right: string): Promise<string> {
|
||||||
const leftBytes = base64urlDecode(left);
|
const leftBytes = base64urlDecode(left);
|
||||||
const rightBytes = base64urlDecode(right);
|
const rightBytes = base64urlDecode(right);
|
||||||
@@ -55,12 +46,7 @@ async function hashPair(left: string, right: string): Promise<string> {
|
|||||||
// Build
|
// Build
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
/**
|
/** Build a Merkle tree from leaf hashes and their corresponding operation IDs. */
|
||||||
* Build a Merkle tree from leaf hashes and their corresponding operation IDs.
|
|
||||||
*
|
|
||||||
* Leaves are sorted lexicographically (with operationIds reordered to match).
|
|
||||||
* If a layer has an odd number of nodes the last node is duplicated.
|
|
||||||
*/
|
|
||||||
export async function buildMerkleTree(
|
export async function buildMerkleTree(
|
||||||
leafHashes: string[],
|
leafHashes: string[],
|
||||||
operationIds: string[],
|
operationIds: string[],
|
||||||
@@ -103,11 +89,7 @@ export async function buildMerkleTree(
|
|||||||
// Proof generation
|
// Proof generation
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
/**
|
/** Generate a Merkle inclusion proof for a given leaf hash. */
|
||||||
* Generate a Merkle inclusion proof for a given leaf hash.
|
|
||||||
*
|
|
||||||
* Returns null if the leaf is not found in the tree.
|
|
||||||
*/
|
|
||||||
export function getMerkleProof(
|
export function getMerkleProof(
|
||||||
tree: MerkleTree,
|
tree: MerkleTree,
|
||||||
leafHash: string,
|
leafHash: string,
|
||||||
@@ -142,9 +124,7 @@ export function getMerkleProof(
|
|||||||
// Proof verification
|
// Proof verification
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
/**
|
/** Verify a Merkle inclusion proof by recomputing the root. */
|
||||||
* Verify a Merkle inclusion proof by recomputing the root.
|
|
||||||
*/
|
|
||||||
export async function verifyMerkleProof(proof: MerkleProof): Promise<boolean> {
|
export async function verifyMerkleProof(proof: MerkleProof): Promise<boolean> {
|
||||||
let current = proof.leaf;
|
let current = proof.leaf;
|
||||||
|
|
||||||
|
|||||||
@@ -1,11 +1,4 @@
|
|||||||
/**
|
/** Cursor-based pagination helpers. */
|
||||||
* Cursor-based pagination helpers.
|
|
||||||
*
|
|
||||||
* Cursors are opaque base64url-encoded JSON objects containing the
|
|
||||||
* created_at timestamp and primary key of the last item in the current
|
|
||||||
* page. This design avoids OFFSET-based pagination which degrades at
|
|
||||||
* scale and is susceptible to drift when rows are inserted concurrently.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { base64urlDecode, base64urlEncode } from './crypto.js';
|
import { base64urlDecode, base64urlEncode } from './crypto.js';
|
||||||
|
|
||||||
@@ -17,24 +10,14 @@ export interface CursorPayload {
|
|||||||
readonly id: string;
|
readonly id: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/** Encode a cursor payload into an opaque cursor string. */
|
||||||
* Encode a cursor payload into an opaque cursor string.
|
|
||||||
*
|
|
||||||
* @param payload - The cursor data to encode
|
|
||||||
* @returns An opaque base64url-encoded cursor string
|
|
||||||
*/
|
|
||||||
export function encodeCursor(payload: CursorPayload): string {
|
export function encodeCursor(payload: CursorPayload): string {
|
||||||
const json = JSON.stringify(payload);
|
const json = JSON.stringify(payload);
|
||||||
const bytes = new TextEncoder().encode(json);
|
const bytes = new TextEncoder().encode(json);
|
||||||
return base64urlEncode(bytes);
|
return base64urlEncode(bytes);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/** Decode an opaque cursor string back into its payload. */
|
||||||
* Decode an opaque cursor string back into its payload.
|
|
||||||
*
|
|
||||||
* @param cursor - The opaque cursor string
|
|
||||||
* @returns The decoded cursor payload, or null if invalid
|
|
||||||
*/
|
|
||||||
export function decodeCursor(cursor: string): CursorPayload | null {
|
export function decodeCursor(cursor: string): CursorPayload | null {
|
||||||
try {
|
try {
|
||||||
const bytes = base64urlDecode(cursor);
|
const bytes = base64urlDecode(cursor);
|
||||||
|
|||||||
@@ -1,15 +1,4 @@
|
|||||||
/**
|
/** Branded PDF generator for Elydora compliance exports. */
|
||||||
* Branded PDF generator for Elydora compliance exports.
|
|
||||||
*
|
|
||||||
* Produces professional multi-page PDF/1.4 documents with:
|
|
||||||
* - Elydora logo and brand header bar
|
|
||||||
* - Structured metadata section
|
|
||||||
* - Formatted operations table with alternating row stripes
|
|
||||||
* - Branded footer with page numbers
|
|
||||||
*
|
|
||||||
* Uses only built-in PDF Type1 fonts (Helvetica, Courier) —
|
|
||||||
* no external dependencies, works entirely in the Node.js runtime.
|
|
||||||
*/
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Types
|
// Types
|
||||||
@@ -46,15 +35,15 @@ const CW = PW - ML - MR; // 500pt content width
|
|||||||
|
|
||||||
// Header bar
|
// Header bar
|
||||||
const HDR_H = 56;
|
const HDR_H = 56;
|
||||||
const HDR_Y = PH - HDR_H; // 736 — bottom edge of header
|
const HDR_Y = PH - HDR_H; // 736: bottom edge of header
|
||||||
|
|
||||||
// Footer
|
// Footer
|
||||||
const FTR_LINE = 44; // y of separator line
|
const FTR_LINE = 44; // y of separator line
|
||||||
const FTR_TEXT = 28; // y of footer text baseline
|
const FTR_TEXT = 28; // y of footer text baseline
|
||||||
|
|
||||||
// Content area
|
// Content area
|
||||||
const CT = HDR_Y - 20; // 716 — content top
|
const CT = HDR_Y - 20; // 716: content top
|
||||||
const CB = FTR_LINE + 12; // 56 — content bottom
|
const CB = FTR_LINE + 12; // 56 : content bottom
|
||||||
|
|
||||||
// Operations table
|
// Operations table
|
||||||
const ROW_H = 12; // row height
|
const ROW_H = 12; // row height
|
||||||
@@ -114,11 +103,7 @@ function txt(s: string, x: number, y: number, font: string, size: number, rgb: s
|
|||||||
return `BT ${rgb} rg ${font} ${size} Tf 1 0 0 1 ${x} ${y} Tm (${esc(s)}) Tj ET`;
|
return `BT ${rgb} rg ${font} ${size} Tf 1 0 0 1 ${x} ${y} Tm (${esc(s)}) Tj ET`;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/** Draw the Elydora "E" logo mark. */
|
||||||
* Draw the Elydora "E" logo mark.
|
|
||||||
* SVG source: M3 2H21V7H8V10H17V15H8V18H21V23H3V2Z (viewBox 0 0 24 24)
|
|
||||||
* Transform flips Y for PDF coordinate system.
|
|
||||||
*/
|
|
||||||
function drawLogo(x: number, y: number, size: number, rgb: string): string {
|
function drawLogo(x: number, y: number, size: number, rgb: string): string {
|
||||||
const sc = size / 24;
|
const sc = size / 24;
|
||||||
return [
|
return [
|
||||||
@@ -131,7 +116,7 @@ function drawLogo(x: number, y: number, size: number, rgb: string): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Section renderers — each appends drawing instructions to the page array
|
// Section renderers: each appends drawing instructions to the page array
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
/** Dark header bar with logo and brand text (every page). */
|
/** Dark header bar with logo and brand text (every page). */
|
||||||
@@ -321,7 +306,7 @@ function assemblePDF(pageStreams: string[]): Uint8Array {
|
|||||||
// Obj 1: Catalog
|
// Obj 1: Catalog
|
||||||
objContents.push('<< /Type /Catalog /Pages 2 0 R >>');
|
objContents.push('<< /Type /Catalog /Pages 2 0 R >>');
|
||||||
|
|
||||||
// Obj 2: Pages (placeholder — patched after page objects are created)
|
// Obj 2: Pages (placeholder: patched after page objects are created)
|
||||||
objContents.push('');
|
objContents.push('');
|
||||||
|
|
||||||
// Obj 3–5: Built-in fonts
|
// Obj 3–5: Built-in fonts
|
||||||
|
|||||||
@@ -1,49 +1,27 @@
|
|||||||
/**
|
/** RFC 3161 Timestamping Authority (TSA) client utilities. */
|
||||||
* RFC 3161 Timestamping Authority (TSA) client utilities.
|
|
||||||
*
|
|
||||||
* Constructs a minimal DER-encoded TimeStampReq and submits it to a
|
|
||||||
* public TSA endpoint. No ASN.1 library required — the 59-byte request
|
|
||||||
* is hand-assembled using the same DER-prefix pattern as the PKCS8
|
|
||||||
* wrapper in crypto.ts.
|
|
||||||
*/
|
|
||||||
|
|
||||||
/** Default public TSA endpoint (Sectigo / Comodo). */
|
/** Default public TSA endpoint (Sectigo / Comodo). */
|
||||||
export const DEFAULT_TSA_URL = 'http://timestamp.sectigo.com';
|
export const DEFAULT_TSA_URL = 'http://timestamp.sectigo.com';
|
||||||
|
|
||||||
/**
|
/** Build a minimal RFC 3161 TimeStampReq (DER-encoded, 59 bytes). */
|
||||||
* Build a minimal RFC 3161 TimeStampReq (DER-encoded, 59 bytes).
|
|
||||||
*
|
|
||||||
* Structure:
|
|
||||||
* SEQUENCE {
|
|
||||||
* INTEGER 1 -- version
|
|
||||||
* SEQUENCE { -- messageImprint
|
|
||||||
* SEQUENCE { -- hashAlgorithm (SHA-256)
|
|
||||||
* OID 2.16.840.1.101.3.4.2.1
|
|
||||||
* NULL
|
|
||||||
* }
|
|
||||||
* OCTET STRING <32 bytes> -- hashedMessage
|
|
||||||
* }
|
|
||||||
* BOOLEAN TRUE -- certReq
|
|
||||||
* }
|
|
||||||
*/
|
|
||||||
export function buildTimeStampReq(sha256Hash: Uint8Array): Uint8Array {
|
export function buildTimeStampReq(sha256Hash: Uint8Array): Uint8Array {
|
||||||
if (sha256Hash.length !== 32) {
|
if (sha256Hash.length !== 32) {
|
||||||
throw new Error(`Expected 32-byte SHA-256 hash, got ${sha256Hash.length}`);
|
throw new Error(`Expected 32-byte SHA-256 hash, got ${sha256Hash.length}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
// DER prefix (24 bytes) — everything before the 32-byte hash
|
// DER prefix (24 bytes): everything before the 32-byte hash
|
||||||
const prefix = new Uint8Array([
|
const prefix = new Uint8Array([
|
||||||
0x30, 0x39, // SEQUENCE (57 bytes)
|
0x30, 0x39, // SEQUENCE (57 bytes)
|
||||||
0x02, 0x01, 0x01, // INTEGER 1 (version v1)
|
0x02, 0x01, 0x01, // INTEGER 1 (version v1)
|
||||||
0x30, 0x31, // SEQUENCE (49 bytes) — MessageImprint
|
0x30, 0x31, // SEQUENCE (49 bytes): MessageImprint
|
||||||
0x30, 0x0d, // SEQUENCE (13 bytes) — AlgorithmIdentifier
|
0x30, 0x0d, // SEQUENCE (13 bytes): AlgorithmIdentifier
|
||||||
0x06, 0x09, // OID (9 bytes)
|
0x06, 0x09, // OID (9 bytes)
|
||||||
0x60, 0x86, 0x48, 0x01, 0x65, 0x03, 0x04, 0x02, 0x01, // SHA-256 (2.16.840.1.101.3.4.2.1)
|
0x60, 0x86, 0x48, 0x01, 0x65, 0x03, 0x04, 0x02, 0x01, // SHA-256 (2.16.840.1.101.3.4.2.1)
|
||||||
0x05, 0x00, // NULL (parameters)
|
0x05, 0x00, // NULL (parameters)
|
||||||
0x04, 0x20, // OCTET STRING (32 bytes)
|
0x04, 0x20, // OCTET STRING (32 bytes)
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// DER suffix (3 bytes) — certReq = TRUE
|
// DER suffix (3 bytes): certReq = TRUE
|
||||||
const suffix = new Uint8Array([0x01, 0x01, 0xff]);
|
const suffix = new Uint8Array([0x01, 0x01, 0xff]);
|
||||||
|
|
||||||
// Assemble: prefix + hash + suffix = 24 + 32 + 3 = 59 bytes
|
// Assemble: prefix + hash + suffix = 24 + 32 + 3 = 59 bytes
|
||||||
@@ -55,13 +33,7 @@ export function buildTimeStampReq(sha256Hash: Uint8Array): Uint8Array {
|
|||||||
return req;
|
return req;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/** Submit a SHA-256 hash to a TSA and return the raw DER TimeStampResp. */
|
||||||
* Submit a SHA-256 hash to a TSA and return the raw DER TimeStampResp.
|
|
||||||
*
|
|
||||||
* @param sha256Hash - 32-byte SHA-256 digest to timestamp
|
|
||||||
* @param tsaUrl - TSA endpoint (defaults to Sectigo)
|
|
||||||
* @returns Raw DER-encoded TimeStampResp bytes
|
|
||||||
*/
|
|
||||||
export async function requestTimestamp(
|
export async function requestTimestamp(
|
||||||
sha256Hash: Uint8Array,
|
sha256Hash: Uint8Array,
|
||||||
tsaUrl: string = DEFAULT_TSA_URL,
|
tsaUrl: string = DEFAULT_TSA_URL,
|
||||||
|
|||||||
@@ -1,24 +1,6 @@
|
|||||||
/**
|
/** UUIDv7 generator conforming to RFC 9562. */
|
||||||
* UUIDv7 generator conforming to RFC 9562.
|
|
||||||
*
|
|
||||||
* UUIDv7 embeds a Unix-millisecond timestamp in the most-significant 48 bits,
|
|
||||||
* making identifiers lexicographically sortable and eliminating the need for
|
|
||||||
* a separate created_at column in many cases.
|
|
||||||
*
|
|
||||||
* This implementation uses the Web Crypto API (crypto.getRandomValues) which
|
|
||||||
* is available natively in Node.js 20+.
|
|
||||||
*/
|
|
||||||
|
|
||||||
/**
|
/** Generate a UUIDv7 string. */
|
||||||
* Generate a UUIDv7 string.
|
|
||||||
*
|
|
||||||
* Layout (128 bits total):
|
|
||||||
* bits 0-47 : Unix timestamp in milliseconds
|
|
||||||
* bits 48-51 : version (0b0111 = 7)
|
|
||||||
* bits 52-63 : random_a (12 bits)
|
|
||||||
* bits 64-65 : variant (0b10)
|
|
||||||
* bits 66-127: random_b (62 bits)
|
|
||||||
*/
|
|
||||||
export function generateUUIDv7(): string {
|
export function generateUUIDv7(): string {
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
const bytes = new Uint8Array(16);
|
const bytes = new Uint8Array(16);
|
||||||
@@ -46,9 +28,7 @@ export function generateUUIDv7(): string {
|
|||||||
return formatUUID(bytes);
|
return formatUUID(bytes);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/** Format a 16-byte array as a standard UUID string (8-4-4-4-12). */
|
||||||
* Format a 16-byte array as a standard UUID string (8-4-4-4-12).
|
|
||||||
*/
|
|
||||||
function formatUUID(bytes: Uint8Array): string {
|
function formatUUID(bytes: Uint8Array): string {
|
||||||
const hex = Array.from(bytes)
|
const hex = Array.from(bytes)
|
||||||
.map((b) => b.toString(16).padStart(2, '0'))
|
.map((b) => b.toString(16).padStart(2, '0'))
|
||||||
|
|||||||
@@ -1,8 +1,4 @@
|
|||||||
/**
|
/** BullMQ queue worker: processes operation, export, and TSA jobs. */
|
||||||
* BullMQ queue worker — processes operation, export, and TSA jobs.
|
|
||||||
*
|
|
||||||
* Run as a separate Node.js process: node dist/worker.js
|
|
||||||
*/
|
|
||||||
|
|
||||||
import 'dotenv/config';
|
import 'dotenv/config';
|
||||||
import { Worker, type Job } from 'bullmq';
|
import { Worker, type Job } from 'bullmq';
|
||||||
|
|||||||
@@ -0,0 +1,135 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { createHash } from 'node:crypto';
|
||||||
|
import test from 'node:test';
|
||||||
|
|
||||||
|
import { Hono } from 'hono';
|
||||||
|
import { authMiddleware } from '../dist/middleware/auth.js';
|
||||||
|
import { auth } from '../dist/routes/auth.js';
|
||||||
|
import { authenticateApiToken, issueApiToken } from '../dist/services/api-token-service.js';
|
||||||
|
|
||||||
|
const NOW = Math.floor(Date.now() / 1000);
|
||||||
|
|
||||||
|
class FakeDatabase {
|
||||||
|
constructor(firstResult = null) {
|
||||||
|
this.firstResult = firstResult;
|
||||||
|
this.statements = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
prepare(sql) {
|
||||||
|
const database = this;
|
||||||
|
const statement = {
|
||||||
|
sql,
|
||||||
|
values: [],
|
||||||
|
bind(...values) {
|
||||||
|
statement.values = values;
|
||||||
|
return statement;
|
||||||
|
},
|
||||||
|
async first() {
|
||||||
|
database.statements.push({ sql, values: statement.values });
|
||||||
|
return database.firstResult;
|
||||||
|
},
|
||||||
|
async run() {
|
||||||
|
database.statements.push({ sql, values: statement.values });
|
||||||
|
return { success: true };
|
||||||
|
},
|
||||||
|
};
|
||||||
|
return statement;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function liveRow(overrides = {}) {
|
||||||
|
return {
|
||||||
|
token_id: 'token-1',
|
||||||
|
user_id: 'user-1',
|
||||||
|
org_id: 'org-1',
|
||||||
|
expires_at: null,
|
||||||
|
user_org_id: 'org-1',
|
||||||
|
user_role: 'integration_engineer',
|
||||||
|
user_status: 'active',
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const ENV = { ELYDORA_DB: null, BETTER_AUTH_SECRET: 'secret', BETTER_AUTH_URL: 'http://localhost', ALLOWED_ORIGINS: '' };
|
||||||
|
|
||||||
|
test('issues a 32-byte token and stores only its hash', async () => {
|
||||||
|
const db = new FakeDatabase();
|
||||||
|
const issued = await issueApiToken(db, 'user-1', 'org-1', 3600);
|
||||||
|
|
||||||
|
assert.equal(Buffer.from(issued.token, 'base64url').length, 32);
|
||||||
|
assert.ok(issued.expires_at >= NOW + 3600);
|
||||||
|
const [insert] = db.statements;
|
||||||
|
assert.match(insert.sql, /INSERT INTO api_tokens/);
|
||||||
|
assert.equal(insert.values[3], createHash('sha256').update(issued.token).digest('hex'));
|
||||||
|
assert.equal(insert.values[5], issued.expires_at);
|
||||||
|
assert.ok(!insert.values.includes(issued.token));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a null ttl issues a token without expiry', async () => {
|
||||||
|
const db = new FakeDatabase();
|
||||||
|
const issued = await issueApiToken(db, 'user-1', 'org-1', null);
|
||||||
|
assert.equal(issued.expires_at, null);
|
||||||
|
assert.equal(db.statements[0].values[5], null);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('authenticates a live token through its hash', async () => {
|
||||||
|
const db = new FakeDatabase(liveRow());
|
||||||
|
const result = await authenticateApiToken(db, 'raw-token');
|
||||||
|
assert.deepEqual(result, { token_id: 'token-1', user_id: 'user-1', org_id: 'org-1', role: 'integration_engineer' });
|
||||||
|
assert.equal(db.statements[0].values[0], createHash('sha256').update('raw-token').digest('hex'));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('rejects unknown, expired, suspended, and re-homed tokens', async () => {
|
||||||
|
for (const row of [
|
||||||
|
null,
|
||||||
|
liveRow({ expires_at: NOW - 1 }),
|
||||||
|
liveRow({ user_status: 'suspended' }),
|
||||||
|
liveRow({ user_org_id: 'org-2' }),
|
||||||
|
]) {
|
||||||
|
assert.equal(await authenticateApiToken(new FakeDatabase(row), 'raw-token'), null);
|
||||||
|
}
|
||||||
|
assert.notEqual(await authenticateApiToken(new FakeDatabase(liveRow({ expires_at: NOW + 60 })), 'raw-token'), null);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('the middleware resolves an API bearer token before Better Auth', async () => {
|
||||||
|
const app = new Hono();
|
||||||
|
app.get('/whoami', authMiddleware, (c) => c.json({
|
||||||
|
org_id: c.get('org_id'),
|
||||||
|
role: c.get('role'),
|
||||||
|
actor: c.get('actor'),
|
||||||
|
auth_token_type: c.get('auth_token_type'),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const response = await app.request(
|
||||||
|
'/whoami',
|
||||||
|
{ headers: { Authorization: 'Bearer raw-token' } },
|
||||||
|
{ ...ENV, ELYDORA_DB: new FakeDatabase(liveRow()) },
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.equal(response.status, 200);
|
||||||
|
assert.deepEqual(await response.json(), {
|
||||||
|
org_id: 'org-1',
|
||||||
|
role: 'integration_engineer',
|
||||||
|
actor: 'user-1',
|
||||||
|
auth_token_type: 'api',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('an API token cannot issue another API token', async () => {
|
||||||
|
const app = new Hono();
|
||||||
|
app.onError((error, c) => c.json({ error: { code: error.errorCode, key: error.messageKey } }, error.statusCode));
|
||||||
|
app.route('/v1/auth', auth);
|
||||||
|
|
||||||
|
const response = await app.request(
|
||||||
|
'/v1/auth/token',
|
||||||
|
{
|
||||||
|
method: 'POST',
|
||||||
|
headers: { Authorization: 'Bearer raw-token', 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ ttl_seconds: null }),
|
||||||
|
},
|
||||||
|
{ ...ENV, ELYDORA_DB: new FakeDatabase(liveRow()) },
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.equal(response.status, 400);
|
||||||
|
assert.deepEqual(await response.json(), { error: { code: 'VALIDATION_ERROR', key: 'auth.issueRequiresSession' } });
|
||||||
|
});
|
||||||
+2
-2
@@ -2,7 +2,7 @@
|
|||||||
set -e
|
set -e
|
||||||
|
|
||||||
# ───────────────────────────────────────────────────────────
|
# ───────────────────────────────────────────────────────────
|
||||||
# Elydora Open Source — One-click deployment
|
# Elydora Open Source installer
|
||||||
# ───────────────────────────────────────────────────────────
|
# ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
printf '\n'
|
printf '\n'
|
||||||
@@ -50,7 +50,7 @@ ok "All prerequisites satisfied."
|
|||||||
cd "$(dirname "$0")/.."
|
cd "$(dirname "$0")/.."
|
||||||
|
|
||||||
if [ -f .env ]; then
|
if [ -f .env ]; then
|
||||||
info "Existing .env found — skipping secret generation."
|
info "Existing .env found; skipping secret generation."
|
||||||
else
|
else
|
||||||
info "Generating .env from .env.example..."
|
info "Generating .env from .env.example..."
|
||||||
|
|
||||||
|
|||||||
@@ -31,3 +31,4 @@ Thumbs.db
|
|||||||
.env.local
|
.env.local
|
||||||
*.pem
|
*.pem
|
||||||
*.key
|
*.key
|
||||||
|
/elydora
|
||||||
+58
-25
@@ -5,11 +5,13 @@ Official Go SDK for the [Elydora](https://elydora.com) tamper-evident audit plat
|
|||||||
## Installation
|
## Installation
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
go get github.com/Elydora-Infrastructure/Elydora-Go-SDK
|
go get github.com/Elydora-Infrastructure/Elydora-Go-SDK/v2
|
||||||
```
|
```
|
||||||
|
|
||||||
Requires Go 1.21+. The CLI uses focused TOML and JSONC parsers to preserve user-owned agent configuration.
|
Requires Go 1.21+. The CLI uses focused TOML and JSONC parsers to preserve user-owned agent configuration.
|
||||||
|
|
||||||
|
Upgrading from 2.0.x: run `elydora install` again for every agent. The generated runtimes changed, and `elydora status` does not detect stale runtimes for every adapter.
|
||||||
|
|
||||||
## Quick Start
|
## Quick Start
|
||||||
|
|
||||||
```go
|
```go
|
||||||
@@ -19,19 +21,16 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"log"
|
"log"
|
||||||
|
|
||||||
elydora "github.com/Elydora-Infrastructure/Elydora-Go-SDK"
|
elydora "github.com/Elydora-Infrastructure/Elydora-Go-SDK/v2"
|
||||||
)
|
)
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
// Initialize the client with your API token.
|
// Create client with an API token issued in the Console
|
||||||
// Obtain an API token by signing in via the Elydora console or:
|
|
||||||
// POST /api/auth/sign-in/email -> get session token
|
|
||||||
// POST /v1/auth/token -> exchange for long-lived API token
|
|
||||||
client, err := elydora.NewClient(&elydora.Config{
|
client, err := elydora.NewClient(&elydora.Config{
|
||||||
OrgID: "org-123",
|
OrgID: "org-123",
|
||||||
AgentID: "my-agent-id",
|
AgentID: "my-agent-id",
|
||||||
PrivateKey: "<base64url-encoded-ed25519-seed>",
|
PrivateKey: "<base64url-encoded-ed25519-seed>",
|
||||||
Token: "your-api-token",
|
Token: "<api-token>",
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Fatal(err)
|
log.Fatal(err)
|
||||||
@@ -63,7 +62,7 @@ The SDK includes a CLI for installing audit hooks into AI coding agents.
|
|||||||
Agent IDs map to one physical directory directly under `~/.elydora`; portable filename rules and physical directory/config checks apply before writes or recursive removal. Ambiguous uninstall discovery requires an explicit agent ID.
|
Agent IDs map to one physical directory directly under `~/.elydora`; portable filename rules and physical directory/config checks apply before writes or recursive removal. Ambiguous uninstall discovery requires an explicit agent ID.
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
go install github.com/Elydora-Infrastructure/Elydora-Go-SDK/cmd/elydora@latest
|
go install github.com/Elydora-Infrastructure/Elydora-Go-SDK/v2/cmd/elydora@latest
|
||||||
|
|
||||||
elydora install \
|
elydora install \
|
||||||
--agent claudecode \
|
--agent claudecode \
|
||||||
@@ -76,6 +75,8 @@ elydora install \
|
|||||||
|
|
||||||
Credential options may be omitted in an interactive terminal; the CLI then reads the private key and optional API token without terminal echo. Credential files must contain one UTF-8 line of at most 64 KiB. Unix credential files require owner-only permissions such as `chmod 600`. Provider settings, `guard.js`, `config.json`, `private.key`, and `hook.js` commit as one rollback-capable transaction, and the generated scripts validate protected files at execution time.
|
Credential options may be omitted in an interactive terminal; the CLI then reads the private key and optional API token without terminal echo. Credential files must contain one UTF-8 line of at most 64 KiB. Unix credential files require owner-only permissions such as `chmod 600`. Provider settings, `guard.js`, `config.json`, `private.key`, and `hook.js` commit as one rollback-capable transaction, and the generated scripts validate protected files at execution time.
|
||||||
|
|
||||||
|
Durable managed-file transactions support Windows, Linux, and macOS. The CLI serializes writers across processes, records append-only recovery state under `~/.elydora/transactions`, stages transaction-owned files on each target filesystem, and completes pending recovery before accepting new writes. Windows requires protected owner-only DACLs, hard-link support, atomic `MoveFileEx` replacement, and writable directory handles with `FlushFileBuffers`; Linux filesystems require `renameat2` with `RENAME_NOREPLACE` and `RENAME_EXCHANGE`; macOS filesystems require `renamex_np` with `RENAME_EXCL` and `RENAME_SWAP`. Unsupported platforms and filesystems fail before managed targets change. Recovery and cleanup verify file and directory identities and retain changed or unexpected objects for manual recovery.
|
||||||
|
|
||||||
Claude Code installation writes exact matchless `PreToolUse`, `PostToolUse`, and `PostToolUseFailure` exec-form hooks to `$CLAUDE_CONFIG_DIR/settings.json` (`~/.claude/settings.json` by default). Elydora preserves unrelated user settings, keeps project, local, managed, plugin, skill, and agent sources unchanged, forwards native snake_case payloads, and uses exit code `2` for frozen or revoked agents. Installation validates the complete official hook schema and commits settings with all four runtime artifacts in one rollback-capable transaction. Run `/hooks` and `claude doctor` after installation to inspect the effective hook sources.
|
Claude Code installation writes exact matchless `PreToolUse`, `PostToolUse`, and `PostToolUseFailure` exec-form hooks to `$CLAUDE_CONFIG_DIR/settings.json` (`~/.claude/settings.json` by default). Elydora preserves unrelated user settings, keeps project, local, managed, plugin, skill, and agent sources unchanged, forwards native snake_case payloads, and uses exit code `2` for frozen or revoked agents. Installation validates the complete official hook schema and commits settings with all four runtime artifacts in one rollback-capable transaction. Run `/hooks` and `claude doctor` after installation to inspect the effective hook sources.
|
||||||
|
|
||||||
Codex installation writes exact `PreToolUse` and `PostToolUse` command groups to `$CODEX_HOME/hooks.json` (`~/.codex/hooks.json` by default) and preserves the complete native event payload. A configured `CODEX_HOME` follows Codex's existing-directory canonicalization rule. User TOML, project, plugin, and managed sources remain unchanged and continue loading additively. The hook file, generated runtimes, runtime config, and private key commit as one rollback-capable update. Run `/hooks` after installation and approve both Elydora definition hashes.
|
Codex installation writes exact `PreToolUse` and `PostToolUse` command groups to `$CODEX_HOME/hooks.json` (`~/.codex/hooks.json` by default) and preserves the complete native event payload. A configured `CODEX_HOME` follows Codex's existing-directory canonicalization rule. User TOML, project, plugin, and managed sources remain unchanged and continue loading additively. The hook file, generated runtimes, runtime config, and private key commit as one rollback-capable update. Run `/hooks` after installation and approve both Elydora definition hashes.
|
||||||
@@ -90,6 +91,8 @@ Cline 3.0.46 installation commits `guard.js`, `config.json`, `private.key`, `hoo
|
|||||||
|
|
||||||
Kiro CLI installation covers both runtime contracts. Kiro CLI v2 uses the generated custom agent through `kiro-cli --agent elydora-audit`. Kiro CLI v3 loads the global standalone hooks when started with `kiro-cli --v3`.
|
Kiro CLI installation covers both runtime contracts. Kiro CLI v2 uses the generated custom agent through `kiro-cli --agent elydora-audit`. Kiro CLI v3 loads the global standalone hooks when started with `kiro-cli --v3`.
|
||||||
|
|
||||||
|
Kiro IDE 1.0 installation writes an exact enabled `PreToolUse` guard and `PostToolUse` audit pair to the active workspace `.kiro/hooks/elydora-audit.json`. Both entries use Kiro's `version: "v1"` schema, `.*` matchers, ten-second timeouts, and platform-safe absolute commands. Elydora preserves unrelated workspace hooks, forwards the complete native event payload, and propagates frozen or revoked state through exit code `2`. Workspace hooks, exact legacy `~/.kiro/hooks/elydora-audit.kiro.hook` migration, generated runtimes, strict runtime metadata, and the canonical private key commit through one rollback-capable transaction. Confirm both entries in Kiro's Agent Hooks panel after installation.
|
||||||
|
|
||||||
Kimi installation follows home-directory evidence. Kimi Code uses `$KIMI_CODE_HOME/config.toml` with `~/.kimi-code/config.toml` as the default, and legacy `kimi-cli` uses `~/.kimi/config.toml`. Elydora installs exact `PreToolUse`, `PostToolUse`, and `PostToolUseFailure` hooks, preserves native snake_case payloads, and commits all selected TOML documents with the four runtime artifacts in one rollback-capable transaction. Windows commands use encoded PowerShell so paths containing spaces, apostrophes, percent signs, and environment-like text remain literal.
|
Kimi installation follows home-directory evidence. Kimi Code uses `$KIMI_CODE_HOME/config.toml` with `~/.kimi-code/config.toml` as the default, and legacy `kimi-cli` uses `~/.kimi/config.toml`. Elydora installs exact `PreToolUse`, `PostToolUse`, and `PostToolUseFailure` hooks, preserves native snake_case payloads, and commits all selected TOML documents with the four runtime artifacts in one rollback-capable transaction. Windows commands use encoded PowerShell so paths containing spaces, apostrophes, percent signs, and environment-like text remain literal.
|
||||||
|
|
||||||
Grok Build installation writes native global `PreToolUse`, `PostToolUse`, and `PostToolUseFailure` hooks to `$GROK_HOME/hooks/elydora-audit.json` (default `~/.grok/hooks/elydora-audit.json`). The hook file, generated runtimes, runtime config, and private key commit as one rollback-capable transaction. Managed commands preserve Grok's native camelCase JSON, return `{"decision":"deny","reason":"..."}` with exit code `2` for frozen or revoked agents, and use encoded PowerShell on Windows. Project hooks follow Grok's `/hooks-trust` workflow; Elydora leaves project, plugin, `hooks-paths`, Claude Code, and Cursor compatibility sources unchanged. Run `grok inspect --json` to inspect the effective hook inventory.
|
Grok Build installation writes native global `PreToolUse`, `PostToolUse`, and `PostToolUseFailure` hooks to `$GROK_HOME/hooks/elydora-audit.json` (default `~/.grok/hooks/elydora-audit.json`). The hook file, generated runtimes, runtime config, and private key commit as one rollback-capable transaction. Managed commands preserve Grok's native camelCase JSON, return `{"decision":"deny","reason":"..."}` with exit code `2` for frozen or revoked agents, and use encoded PowerShell on Windows. Project hooks follow Grok's `/hooks-trust` workflow; Elydora leaves project, plugin, `hooks-paths`, Claude Code, and Cursor compatibility sources unchanged. Run `grok inspect --json` to inspect the effective hook inventory.
|
||||||
@@ -106,8 +109,9 @@ Qwen Code `0.20.0` installation writes native user hooks to `$QWEN_HOME/settings
|
|||||||
|---------|-------------|
|
|---------|-------------|
|
||||||
| `elydora install` | Install Elydora audit hook for a coding agent |
|
| `elydora install` | Install Elydora audit hook for a coding agent |
|
||||||
| `elydora uninstall` | Remove Elydora audit hook for a coding agent |
|
| `elydora uninstall` | Remove Elydora audit hook for a coding agent |
|
||||||
| `elydora status` | Show installation status for all agents |
|
| `elydora status` | Show installation status for one agent or all agents |
|
||||||
| `elydora agents` | List supported coding agents |
|
| `elydora agents` | List supported coding agents |
|
||||||
|
| `elydora version` | Print the CLI version |
|
||||||
|
|
||||||
### Supported Agents
|
### Supported Agents
|
||||||
|
|
||||||
@@ -154,24 +158,24 @@ client.SetToken("new-api-token")
|
|||||||
|
|
||||||
### Authentication
|
### Authentication
|
||||||
|
|
||||||
Authentication uses Better Auth. Register and sign in via the Elydora console or the Better Auth endpoints, then issue a long-lived API token for SDK use:
|
|
||||||
|
|
||||||
```go
|
```go
|
||||||
// Sign up (Better Auth) — use the console or call directly:
|
// Get the authenticated user and current organization
|
||||||
// POST /api/auth/sign-up/email { email, password, name }
|
me, err := client.GetMe()
|
||||||
//
|
// me.User.Email, me.User.Role, me.CurrentOrganization.OrgID
|
||||||
// Sign in (Better Auth) — get a session token:
|
|
||||||
// POST /api/auth/sign-in/email { email, password }
|
// Issue an API token; this call needs a client whose Token is a Console session token
|
||||||
//
|
|
||||||
// Issue a long-lived API token from an active session:
|
|
||||||
tokenResp, err := client.IssueApiToken(&elydora.IssueApiTokenRequest{
|
tokenResp, err := client.IssueApiToken(&elydora.IssueApiTokenRequest{
|
||||||
TTLSeconds: &ttlSeconds,
|
TTLSeconds: &ttlSeconds,
|
||||||
})
|
})
|
||||||
|
client.SetToken(tokenResp.Token)
|
||||||
|
|
||||||
// Update the token on an existing client instance at runtime
|
// Rotate the installed API token; the response carries the replacement
|
||||||
client.SetToken("new-api-token")
|
rotated, err := client.RotateApiToken()
|
||||||
|
client.SetToken(rotated.Token)
|
||||||
```
|
```
|
||||||
|
|
||||||
|
`elydora.Register` and `elydora.Login` remain for password accounts and are deprecated; use the Console sign-in flow.
|
||||||
|
|
||||||
### Operations
|
### Operations
|
||||||
|
|
||||||
```go
|
```go
|
||||||
@@ -211,8 +215,11 @@ agent, err := client.RegisterAgent(&elydora.RegisterAgentRequest{
|
|||||||
// Get agent details
|
// Get agent details
|
||||||
details, err := client.GetAgent(agentID)
|
details, err := client.GetAgent(agentID)
|
||||||
|
|
||||||
|
// Change the integration type
|
||||||
|
updated, err := client.UpdateAgent(agentID, elydora.IntegrationTypeClaudecode)
|
||||||
|
|
||||||
// Freeze an agent
|
// Freeze an agent
|
||||||
err := client.FreezeAgent(agentID, "security review")
|
frozen, err := client.FreezeAgent(agentID, "security review")
|
||||||
|
|
||||||
// Revoke a key
|
// Revoke a key
|
||||||
err := client.RevokeKey(agentID, kid, "key rotation")
|
err := client.RevokeKey(agentID, kid, "key rotation")
|
||||||
@@ -221,7 +228,7 @@ err := client.RevokeKey(agentID, kid, "key rotation")
|
|||||||
agents, err := client.ListAgents()
|
agents, err := client.ListAgents()
|
||||||
|
|
||||||
// Unfreeze a previously frozen agent
|
// Unfreeze a previously frozen agent
|
||||||
err := client.UnfreezeAgent(agentID, "review complete")
|
unfrozen, err := client.UnfreezeAgent(agentID, "review complete")
|
||||||
|
|
||||||
// Delete an agent permanently
|
// Delete an agent permanently
|
||||||
deleted, err := client.DeleteAgent(agentID)
|
deleted, err := client.DeleteAgent(agentID)
|
||||||
@@ -262,6 +269,25 @@ detail, err := client.GetExport(exportID)
|
|||||||
data, err := client.DownloadExport(exportID)
|
data, err := client.DownloadExport(exportID)
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### Webhooks
|
||||||
|
|
||||||
|
```go
|
||||||
|
webhook, err := client.RegisterWebhook(&elydora.RegisterWebhookRequest{
|
||||||
|
EndpointURL: "https://example.com/elydora",
|
||||||
|
Events: []string{"operation.accepted"},
|
||||||
|
Secret: "<signing-secret>",
|
||||||
|
})
|
||||||
|
webhooks, err := client.ListWebhooks()
|
||||||
|
err = client.DeleteWebhook(webhook.Webhook.WebhookID)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Organization
|
||||||
|
|
||||||
|
```go
|
||||||
|
members, err := client.ListMembers()
|
||||||
|
events, err := client.ListAdminEvents(50)
|
||||||
|
```
|
||||||
|
|
||||||
### JWKS
|
### JWKS
|
||||||
|
|
||||||
```go
|
```go
|
||||||
@@ -273,13 +299,17 @@ jwks, err := client.GetJWKS()
|
|||||||
```go
|
```go
|
||||||
// Check API health (no authentication required, does not need a client)
|
// Check API health (no authentication required, does not need a client)
|
||||||
health, err := elydora.Health("https://api.elydora.com")
|
health, err := elydora.Health("https://api.elydora.com")
|
||||||
// health.Status, health.Version, health.ProtocolVersion, health.Timestamp
|
// health.Status, health.Version, health.ProtocolVersion, health.Capabilities, health.Timestamp
|
||||||
|
|
||||||
|
// Check dependency health (D1, auth storage, R2, KV)
|
||||||
|
deep, err := elydora.DeepHealth("https://api.elydora.com")
|
||||||
|
// deep.Dependencies.D1.Status, deep.Dependencies.KV.LatencyMs
|
||||||
```
|
```
|
||||||
|
|
||||||
### Constants
|
### Constants
|
||||||
|
|
||||||
```go
|
```go
|
||||||
// Genesis chain hash — initial prev_chain_hash for an agent's first operation
|
// Genesis chain hash: initial prev_chain_hash for an agent's first operation
|
||||||
elydora.GenesisChainHash // "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"
|
elydora.GenesisChainHash // "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -291,8 +321,11 @@ elydora.GenesisChainHash // "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"
|
|||||||
| `KeyStatus` | `KeyStatusActive`, `KeyStatusRetired`, `KeyStatusRevoked` |
|
| `KeyStatus` | `KeyStatusActive`, `KeyStatusRetired`, `KeyStatusRevoked` |
|
||||||
| `ExportStatus` | `ExportStatusQueued`, `ExportStatusRunning`, `ExportStatusDone`, `ExportStatusFailed` |
|
| `ExportStatus` | `ExportStatusQueued`, `ExportStatusRunning`, `ExportStatusDone`, `ExportStatusFailed` |
|
||||||
| `ExportFormat` | `ExportFormatJSON`, `ExportFormatPDF` |
|
| `ExportFormat` | `ExportFormatJSON`, `ExportFormatPDF` |
|
||||||
|
| `WebhookStatus` | `WebhookStatusActive`, `WebhookStatusDisabled` |
|
||||||
|
| `IntegrationType` | One constant per supported agent key plus `IntegrationTypeEnterprise`, `IntegrationTypeGUI`, `IntegrationTypeSDK`, `IntegrationTypeOther` |
|
||||||
|
| `AdminAction` | One constant per admin event action, for example `AdminActionAgentFreeze` |
|
||||||
| `RbacRole` | `RbacRoleOrgOwner`, `RbacRoleSecurityAdmin`, `RbacRoleComplianceAuditor`, `RbacRoleReadonlyInvestigator`, `RbacRoleIntegrationEngineer` |
|
| `RbacRole` | `RbacRoleOrgOwner`, `RbacRoleSecurityAdmin`, `RbacRoleComplianceAuditor`, `RbacRoleReadonlyInvestigator`, `RbacRoleIntegrationEngineer` |
|
||||||
| `ErrorCode` | `ErrorCodeInvalidSignature`, `ErrorCodeUnknownAgent`, `ErrorCodeKeyRevoked`, `ErrorCodeAgentFrozen`, `ErrorCodeTTLExpired`, `ErrorCodeReplayDetected`, `ErrorCodePrevHashMismatch`, `ErrorCodePayloadTooLarge`, `ErrorCodeRateLimited`, `ErrorCodeInternalError`, `ErrorCodeUnauthorized`, `ErrorCodeForbidden`, `ErrorCodeNotFound`, `ErrorCodeValidationError` |
|
| `ErrorCode` | `ErrorCodeInvalidSignature`, `ErrorCodeUnknownAgent`, `ErrorCodeKeyRevoked`, `ErrorCodeKeyRetired`, `ErrorCodeAgentFrozen`, `ErrorCodeAgentRevoked`, `ErrorCodeTTLExpired`, `ErrorCodeReplayDetected`, `ErrorCodePrevHashMismatch`, `ErrorCodePayloadTooLarge`, `ErrorCodeRateLimited`, `ErrorCodeInternalError`, `ErrorCodeUnauthorized`, `ErrorCodeForbidden`, `ErrorCodeNotFound`, `ErrorCodeValidationError` |
|
||||||
|
|
||||||
## Error Handling
|
## Error Handling
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,16 @@
|
|||||||
|
package elydora
|
||||||
|
|
||||||
|
import "fmt"
|
||||||
|
|
||||||
|
// ListAdminEvents retrieves recent administrative events; limit 0 uses the server default.
|
||||||
|
func (c *Client) ListAdminEvents(limit int) (*ListAdminEventsResponse, error) {
|
||||||
|
path := "/v1/admin/events"
|
||||||
|
if limit > 0 {
|
||||||
|
path = fmt.Sprintf("%s?limit=%d", path, limit)
|
||||||
|
}
|
||||||
|
var result ListAdminEventsResponse
|
||||||
|
if err := c.doGet(path, &result); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &result, nil
|
||||||
|
}
|
||||||
+29
-16
@@ -3,17 +3,11 @@ package elydora
|
|||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"net/url"
|
||||||
)
|
)
|
||||||
|
|
||||||
// RegisterAgent registers a new AI agent within the organization.
|
// RegisterAgent registers a new AI agent within the organization.
|
||||||
func (c *Client) RegisterAgent(req *RegisterAgentRequest) (*RegisterAgentResponse, error) {
|
func (c *Client) RegisterAgent(req *RegisterAgentRequest) (*RegisterAgentResponse, error) {
|
||||||
if req == nil {
|
|
||||||
return nil, fmt.Errorf("elydora: register agent request must not be nil")
|
|
||||||
}
|
|
||||||
if !req.IntegrationType.IsValid() {
|
|
||||||
return nil, fmt.Errorf("elydora: invalid integration_type %q", req.IntegrationType)
|
|
||||||
}
|
|
||||||
|
|
||||||
var result RegisterAgentResponse
|
var result RegisterAgentResponse
|
||||||
if err := c.doPost("/v1/agents/register", req, &result); err != nil {
|
if err := c.doPost("/v1/agents/register", req, &result); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -24,22 +18,26 @@ func (c *Client) RegisterAgent(req *RegisterAgentRequest) (*RegisterAgentRespons
|
|||||||
// GetAgent retrieves an agent by ID.
|
// GetAgent retrieves an agent by ID.
|
||||||
func (c *Client) GetAgent(agentID string) (*GetAgentResponse, error) {
|
func (c *Client) GetAgent(agentID string) (*GetAgentResponse, error) {
|
||||||
var result GetAgentResponse
|
var result GetAgentResponse
|
||||||
if err := c.doGet(fmt.Sprintf("/v1/agents/%s", agentID), &result); err != nil {
|
if err := c.doGet(fmt.Sprintf("/v1/agents/%s", url.PathEscape(agentID)), &result); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
return &result, nil
|
return &result, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// FreezeAgent freezes an agent, preventing it from submitting operations.
|
// FreezeAgent freezes an agent, preventing it from submitting operations.
|
||||||
func (c *Client) FreezeAgent(agentID, reason string) error {
|
func (c *Client) FreezeAgent(agentID, reason string) (*FreezeAgentResponse, error) {
|
||||||
return c.doPost(fmt.Sprintf("/v1/agents/%s/freeze", agentID), &FreezeAgentRequest{
|
var result FreezeAgentResponse
|
||||||
|
if err := c.doPost(fmt.Sprintf("/v1/agents/%s/freeze", url.PathEscape(agentID)), &FreezeAgentRequest{
|
||||||
Reason: reason,
|
Reason: reason,
|
||||||
}, nil)
|
}, &result); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &result, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// RevokeKey revokes a specific key for an agent.
|
// RevokeKey revokes a specific key for an agent.
|
||||||
func (c *Client) RevokeKey(agentID, kid, reason string) error {
|
func (c *Client) RevokeKey(agentID, kid, reason string) error {
|
||||||
return c.doPost(fmt.Sprintf("/v1/agents/%s/revoke", agentID), &RevokeAgentRequest{
|
return c.doPost(fmt.Sprintf("/v1/agents/%s/revoke", url.PathEscape(agentID)), &RevokeAgentRequest{
|
||||||
KID: kid,
|
KID: kid,
|
||||||
Reason: reason,
|
Reason: reason,
|
||||||
}, nil)
|
}, nil)
|
||||||
@@ -55,16 +53,31 @@ func (c *Client) ListAgents() (*ListAgentsResponse, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// UnfreezeAgent unfreezes a previously frozen agent.
|
// UnfreezeAgent unfreezes a previously frozen agent.
|
||||||
func (c *Client) UnfreezeAgent(agentID, reason string) error {
|
func (c *Client) UnfreezeAgent(agentID, reason string) (*UnfreezeAgentResponse, error) {
|
||||||
return c.doPost(fmt.Sprintf("/v1/agents/%s/unfreeze", agentID), &UnfreezeAgentRequest{
|
var result UnfreezeAgentResponse
|
||||||
|
if err := c.doPost(fmt.Sprintf("/v1/agents/%s/unfreeze", url.PathEscape(agentID)), &UnfreezeAgentRequest{
|
||||||
Reason: reason,
|
Reason: reason,
|
||||||
}, nil)
|
}, &result); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateAgent updates an agent's integration type.
|
||||||
|
func (c *Client) UpdateAgent(agentID string, integrationType IntegrationType) (*UpdateAgentResponse, error) {
|
||||||
|
var result UpdateAgentResponse
|
||||||
|
if err := c.doRequest(http.MethodPatch, fmt.Sprintf("/v1/agents/%s", url.PathEscape(agentID)), &UpdateAgentRequest{
|
||||||
|
IntegrationType: integrationType,
|
||||||
|
}, &result); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &result, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// DeleteAgent permanently deletes an agent and all associated data.
|
// DeleteAgent permanently deletes an agent and all associated data.
|
||||||
func (c *Client) DeleteAgent(agentID string) (*DeleteAgentResponse, error) {
|
func (c *Client) DeleteAgent(agentID string) (*DeleteAgentResponse, error) {
|
||||||
var result DeleteAgentResponse
|
var result DeleteAgentResponse
|
||||||
if err := c.doRequest(http.MethodDelete, fmt.Sprintf("/v1/agents/%s", agentID), nil, &result); err != nil {
|
if err := c.doRequest(http.MethodDelete, fmt.Sprintf("/v1/agents/%s", url.PathEscape(agentID)), nil, &result); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
return &result, nil
|
return &result, nil
|
||||||
|
|||||||
+19
-95
@@ -1,51 +1,30 @@
|
|||||||
package elydora
|
package elydora
|
||||||
|
|
||||||
import (
|
import "net/http"
|
||||||
"bytes"
|
|
||||||
"encoding/json"
|
|
||||||
"fmt"
|
|
||||||
"io"
|
|
||||||
"net/http"
|
|
||||||
"strings"
|
|
||||||
"time"
|
|
||||||
)
|
|
||||||
|
|
||||||
// RegisterOption is a functional option for the Register function.
|
// RegisterOption configures a registration request.
|
||||||
type RegisterOption func(*AuthRegisterRequest)
|
type RegisterOption func(*AuthRegisterRequest)
|
||||||
|
|
||||||
// WithDisplayName sets the display_name on a registration request.
|
// WithDisplayName sets the display_name on a registration request.
|
||||||
func WithDisplayName(name string) RegisterOption {
|
func WithDisplayName(name string) RegisterOption {
|
||||||
return func(req *AuthRegisterRequest) {
|
return func(req *AuthRegisterRequest) { req.DisplayName = name }
|
||||||
req.DisplayName = name
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// WithOrgName sets the org_name on a registration request.
|
// WithOrgName sets the org_name on a registration request.
|
||||||
func WithOrgName(name string) RegisterOption {
|
func WithOrgName(name string) RegisterOption {
|
||||||
return func(req *AuthRegisterRequest) {
|
return func(req *AuthRegisterRequest) { req.OrgName = name }
|
||||||
req.OrgName = name
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Register creates a new user and organization.
|
// Register creates a new user and organization.
|
||||||
//
|
//
|
||||||
// Deprecated: Use Better Auth endpoints directly. See docs.
|
// Deprecated: use the Console sign-up flow; password registration is being phased out.
|
||||||
func Register(baseURL, email, password string, opts ...RegisterOption) (*AuthRegisterResponse, error) {
|
func Register(baseURL, email, password string, opts ...RegisterOption) (*AuthRegisterResponse, error) {
|
||||||
if baseURL == "" {
|
req := &AuthRegisterRequest{Email: email, Password: password}
|
||||||
baseURL = defaultBaseURL
|
|
||||||
}
|
|
||||||
baseURL = strings.TrimRight(baseURL, "/")
|
|
||||||
|
|
||||||
req := &AuthRegisterRequest{
|
|
||||||
Email: email,
|
|
||||||
Password: password,
|
|
||||||
}
|
|
||||||
for _, opt := range opts {
|
for _, opt := range opts {
|
||||||
opt(req)
|
opt(req)
|
||||||
}
|
}
|
||||||
|
|
||||||
var result AuthRegisterResponse
|
var result AuthRegisterResponse
|
||||||
if err := authPost(baseURL, "/v1/auth/register", req, &result); err != nil {
|
if err := publicRequest(http.MethodPost, normalizeBaseURL(baseURL)+"/v1/auth/register", req, &result); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
return &result, nil
|
return &result, nil
|
||||||
@@ -53,80 +32,16 @@ func Register(baseURL, email, password string, opts ...RegisterOption) (*AuthReg
|
|||||||
|
|
||||||
// Login authenticates a user and returns a session token.
|
// Login authenticates a user and returns a session token.
|
||||||
//
|
//
|
||||||
// Deprecated: Use Better Auth endpoints directly. See docs.
|
// Deprecated: use the Console sign-in flow; password login is being phased out.
|
||||||
func Login(baseURL, email, password string) (*AuthLoginResponse, error) {
|
func Login(baseURL, email, password string) (*AuthLoginResponse, error) {
|
||||||
if baseURL == "" {
|
req := &AuthLoginRequest{Email: email, Password: password}
|
||||||
baseURL = defaultBaseURL
|
|
||||||
}
|
|
||||||
baseURL = strings.TrimRight(baseURL, "/")
|
|
||||||
|
|
||||||
req := &AuthLoginRequest{
|
|
||||||
Email: email,
|
|
||||||
Password: password,
|
|
||||||
}
|
|
||||||
|
|
||||||
var result AuthLoginResponse
|
var result AuthLoginResponse
|
||||||
if err := authPost(baseURL, "/v1/auth/login", req, &result); err != nil {
|
if err := publicRequest(http.MethodPost, normalizeBaseURL(baseURL)+"/v1/auth/login", req, &result); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
return &result, nil
|
return &result, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// authPost is a helper for unauthenticated POST requests used by Register and Login.
|
|
||||||
func authPost(baseURL, path string, body interface{}, result interface{}) error {
|
|
||||||
u := baseURL + path
|
|
||||||
|
|
||||||
data, err := json.Marshal(body)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("elydora: marshal request body: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
client := &http.Client{Timeout: 30 * time.Second}
|
|
||||||
req, err := http.NewRequest(http.MethodPost, u, bytes.NewReader(data))
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("elydora: create request: %w", err)
|
|
||||||
}
|
|
||||||
req.Header.Set("Content-Type", "application/json")
|
|
||||||
req.Header.Set("Accept", "application/json")
|
|
||||||
|
|
||||||
resp, err := client.Do(req)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("elydora: http request: %w", err)
|
|
||||||
}
|
|
||||||
defer resp.Body.Close()
|
|
||||||
|
|
||||||
respBody, err := io.ReadAll(io.LimitReader(resp.Body, 10<<20))
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("elydora: read response body: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if resp.StatusCode >= 200 && resp.StatusCode < 300 {
|
|
||||||
if result != nil && len(respBody) > 0 {
|
|
||||||
if err := json.Unmarshal(respBody, result); err != nil {
|
|
||||||
return fmt.Errorf("elydora: unmarshal response: %w", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
var errResp errorResponse
|
|
||||||
if err := json.Unmarshal(respBody, &errResp); err == nil && errResp.Error.Code != "" {
|
|
||||||
return &ElydoraError{
|
|
||||||
StatusCode: resp.StatusCode,
|
|
||||||
Code: errResp.Error.Code,
|
|
||||||
Message: errResp.Error.Message,
|
|
||||||
RequestID: errResp.Error.RequestID,
|
|
||||||
Details: errResp.Error.Details,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return &ElydoraError{
|
|
||||||
StatusCode: resp.StatusCode,
|
|
||||||
Code: ErrorCodeInternalError,
|
|
||||||
Message: string(respBody),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetMe retrieves the current authenticated user's profile.
|
// GetMe retrieves the current authenticated user's profile.
|
||||||
func (c *Client) GetMe() (*GetMeResponse, error) {
|
func (c *Client) GetMe() (*GetMeResponse, error) {
|
||||||
var result GetMeResponse
|
var result GetMeResponse
|
||||||
@@ -144,3 +59,12 @@ func (c *Client) IssueApiToken(req *IssueApiTokenRequest) (*IssueApiTokenRespons
|
|||||||
}
|
}
|
||||||
return &result, nil
|
return &result, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// RotateApiToken rotates the current API token; the old token stays valid for 24h.
|
||||||
|
func (c *Client) RotateApiToken() (*RotateApiTokenResponse, error) {
|
||||||
|
var result RotateApiTokenResponse
|
||||||
|
if err := c.doPost("/v1/auth/rotate", map[string]interface{}{}, &result); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &result, nil
|
||||||
|
}
|
||||||
+172
-110
@@ -14,6 +14,8 @@ const (
|
|||||||
defaultBaseURL = "https://api.elydora.com"
|
defaultBaseURL = "https://api.elydora.com"
|
||||||
defaultTTLMs = 30000
|
defaultTTLMs = 30000
|
||||||
defaultMaxRetries = 3
|
defaultMaxRetries = 3
|
||||||
|
maxResponseBytes = 10 << 20
|
||||||
|
requestTimeout = 30 * time.Second
|
||||||
)
|
)
|
||||||
|
|
||||||
// Config holds the configuration for creating a new Client.
|
// Config holds the configuration for creating a new Client.
|
||||||
@@ -44,135 +46,195 @@ func NewClient(cfg *Config) (*Client, error) {
|
|||||||
if cfg == nil {
|
if cfg == nil {
|
||||||
return nil, fmt.Errorf("elydora: config must not be nil")
|
return nil, fmt.Errorf("elydora: config must not be nil")
|
||||||
}
|
}
|
||||||
|
|
||||||
baseURL := cfg.BaseURL
|
|
||||||
if baseURL == "" {
|
|
||||||
baseURL = defaultBaseURL
|
|
||||||
}
|
|
||||||
baseURL = strings.TrimRight(baseURL, "/")
|
|
||||||
|
|
||||||
ttlMs := cfg.TTLMs
|
ttlMs := cfg.TTLMs
|
||||||
if ttlMs <= 0 {
|
if ttlMs <= 0 {
|
||||||
ttlMs = defaultTTLMs
|
ttlMs = defaultTTLMs
|
||||||
}
|
}
|
||||||
|
|
||||||
maxRetries := cfg.MaxRetries
|
maxRetries := cfg.MaxRetries
|
||||||
if maxRetries <= 0 {
|
if maxRetries <= 0 {
|
||||||
maxRetries = defaultMaxRetries
|
maxRetries = defaultMaxRetries
|
||||||
}
|
}
|
||||||
|
|
||||||
return &Client{
|
return &Client{
|
||||||
orgID: cfg.OrgID,
|
orgID: cfg.OrgID,
|
||||||
agentID: cfg.AgentID,
|
agentID: cfg.AgentID,
|
||||||
privateKey: cfg.PrivateKey,
|
privateKey: cfg.PrivateKey,
|
||||||
baseURL: baseURL,
|
baseURL: normalizeBaseURL(cfg.BaseURL),
|
||||||
ttlMs: ttlMs,
|
ttlMs: ttlMs,
|
||||||
maxRetries: maxRetries,
|
maxRetries: maxRetries,
|
||||||
token: cfg.Token,
|
token: cfg.Token,
|
||||||
httpClient: &http.Client{
|
httpClient: &http.Client{Timeout: requestTimeout},
|
||||||
Timeout: 30 * time.Second,
|
|
||||||
},
|
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// doRequest executes an HTTP request with retries and error handling.
|
|
||||||
func (c *Client) doRequest(method, path string, body interface{}, result interface{}) error {
|
|
||||||
u := c.baseURL + path
|
|
||||||
|
|
||||||
var bodyReader io.Reader
|
|
||||||
if body != nil {
|
|
||||||
data, err := json.Marshal(body)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("elydora: marshal request body: %w", err)
|
|
||||||
}
|
|
||||||
bodyReader = bytes.NewReader(data)
|
|
||||||
}
|
|
||||||
|
|
||||||
var lastErr error
|
|
||||||
for attempt := 0; attempt <= c.maxRetries; attempt++ {
|
|
||||||
if attempt > 0 {
|
|
||||||
// Reset body reader for retry
|
|
||||||
if body != nil {
|
|
||||||
data, _ := json.Marshal(body)
|
|
||||||
bodyReader = bytes.NewReader(data)
|
|
||||||
}
|
|
||||||
time.Sleep(time.Duration(attempt*attempt) * 100 * time.Millisecond)
|
|
||||||
}
|
|
||||||
|
|
||||||
req, err := http.NewRequest(method, u, bodyReader)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("elydora: create request: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
req.Header.Set("Content-Type", "application/json")
|
|
||||||
req.Header.Set("Accept", "application/json")
|
|
||||||
if c.token != "" {
|
|
||||||
req.Header.Set("Authorization", "Bearer "+c.token)
|
|
||||||
}
|
|
||||||
|
|
||||||
resp, err := c.httpClient.Do(req)
|
|
||||||
if err != nil {
|
|
||||||
lastErr = fmt.Errorf("elydora: http request: %w", err)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
respBody, err := io.ReadAll(io.LimitReader(resp.Body, 10<<20))
|
|
||||||
resp.Body.Close()
|
|
||||||
if err != nil {
|
|
||||||
lastErr = fmt.Errorf("elydora: read response body: %w", err)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
if resp.StatusCode >= 200 && resp.StatusCode < 300 {
|
|
||||||
if result != nil && len(respBody) > 0 {
|
|
||||||
if err := json.Unmarshal(respBody, result); err != nil {
|
|
||||||
return fmt.Errorf("elydora: unmarshal response: %w", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Parse error response
|
|
||||||
var errResp errorResponse
|
|
||||||
if err := json.Unmarshal(respBody, &errResp); err == nil && errResp.Error.Code != "" {
|
|
||||||
apiErr := &ElydoraError{
|
|
||||||
StatusCode: resp.StatusCode,
|
|
||||||
Code: errResp.Error.Code,
|
|
||||||
Message: errResp.Error.Message,
|
|
||||||
RequestID: errResp.Error.RequestID,
|
|
||||||
Details: errResp.Error.Details,
|
|
||||||
}
|
|
||||||
// Don't retry 4xx errors (except 429)
|
|
||||||
if resp.StatusCode < 500 && resp.StatusCode != 429 {
|
|
||||||
return apiErr
|
|
||||||
}
|
|
||||||
lastErr = apiErr
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
lastErr = &ElydoraError{
|
|
||||||
StatusCode: resp.StatusCode,
|
|
||||||
Code: ErrorCodeInternalError,
|
|
||||||
Message: string(respBody),
|
|
||||||
}
|
|
||||||
if resp.StatusCode < 500 && resp.StatusCode != 429 {
|
|
||||||
return lastErr
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return lastErr
|
|
||||||
}
|
|
||||||
|
|
||||||
// doGet performs a GET request.
|
|
||||||
func (c *Client) doGet(path string, result interface{}) error {
|
|
||||||
return c.doRequest(http.MethodGet, path, nil, result)
|
|
||||||
}
|
|
||||||
|
|
||||||
// doPost performs a POST request.
|
|
||||||
func (c *Client) doPost(path string, body interface{}, result interface{}) error {
|
|
||||||
return c.doRequest(http.MethodPost, path, body, result)
|
|
||||||
}
|
|
||||||
|
|
||||||
// SetToken sets the API token used for authenticated API requests.
|
// SetToken sets the API token used for authenticated API requests.
|
||||||
func (c *Client) SetToken(token string) {
|
func (c *Client) SetToken(token string) {
|
||||||
c.token = token
|
c.token = token
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func normalizeBaseURL(baseURL string) string {
|
||||||
|
if baseURL == "" {
|
||||||
|
baseURL = defaultBaseURL
|
||||||
|
}
|
||||||
|
return strings.TrimRight(baseURL, "/")
|
||||||
|
}
|
||||||
|
|
||||||
|
func newRequest(method, url string, body []byte, token, accept string) (*http.Request, error) {
|
||||||
|
var reader io.Reader
|
||||||
|
if body != nil {
|
||||||
|
reader = bytes.NewReader(body)
|
||||||
|
}
|
||||||
|
req, err := http.NewRequest(method, url, reader)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("elydora: create request: %w", err)
|
||||||
|
}
|
||||||
|
if accept != "" {
|
||||||
|
req.Header.Set("Accept", accept)
|
||||||
|
}
|
||||||
|
if body != nil {
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
}
|
||||||
|
if token != "" {
|
||||||
|
req.Header.Set("Authorization", "Bearer "+token)
|
||||||
|
}
|
||||||
|
return req, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func marshalBody(body interface{}) ([]byte, error) {
|
||||||
|
if body == nil {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
data, err := json.Marshal(body)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("elydora: marshal request body: %w", err)
|
||||||
|
}
|
||||||
|
return data, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func readBody(resp *http.Response, limit int64) ([]byte, error) {
|
||||||
|
defer resp.Body.Close()
|
||||||
|
var reader io.Reader = resp.Body
|
||||||
|
if limit > 0 {
|
||||||
|
reader = io.LimitReader(resp.Body, limit)
|
||||||
|
}
|
||||||
|
body, err := io.ReadAll(reader)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("elydora: read response body: %w", err)
|
||||||
|
}
|
||||||
|
return body, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func decodeSuccess(body []byte, result interface{}) error {
|
||||||
|
if result == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(body, result); err != nil {
|
||||||
|
return fmt.Errorf("elydora: unmarshal response: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func apiError(status int, body []byte) *ElydoraError {
|
||||||
|
var errResp errorResponse
|
||||||
|
if err := json.Unmarshal(body, &errResp); err == nil && errResp.Error.Code != "" {
|
||||||
|
return &ElydoraError{
|
||||||
|
StatusCode: status,
|
||||||
|
Code: errResp.Error.Code,
|
||||||
|
Message: errResp.Error.Message,
|
||||||
|
RequestID: errResp.Error.RequestID,
|
||||||
|
Details: errResp.Error.Details,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return &ElydoraError{StatusCode: status, Code: ErrorCodeInternalError, Message: string(body)}
|
||||||
|
}
|
||||||
|
|
||||||
|
func retryable(status int) bool {
|
||||||
|
return status >= 500 || status == http.StatusTooManyRequests
|
||||||
|
}
|
||||||
|
|
||||||
|
// A lost response to a non-idempotent request must not be replayed.
|
||||||
|
func idempotent(method string) bool {
|
||||||
|
switch method {
|
||||||
|
case http.MethodGet, http.MethodHead, http.MethodOptions, http.MethodPut, http.MethodDelete:
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// doRequest executes an authenticated request; only idempotent methods retry.
|
||||||
|
func (c *Client) doRequest(method, path string, body interface{}, result interface{}) error {
|
||||||
|
data, err := marshalBody(body)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
var lastErr error
|
||||||
|
for attempt := 0; attempt <= c.maxRetries; attempt++ {
|
||||||
|
if attempt > 0 {
|
||||||
|
time.Sleep(time.Duration(attempt*attempt) * 100 * time.Millisecond)
|
||||||
|
}
|
||||||
|
req, err := newRequest(method, c.baseURL+path, data, c.token, "application/json")
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
resp, err := c.httpClient.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
lastErr = fmt.Errorf("elydora: http request: %w", err)
|
||||||
|
if !idempotent(method) {
|
||||||
|
return lastErr
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
respBody, err := readBody(resp, maxResponseBytes)
|
||||||
|
if err != nil {
|
||||||
|
lastErr = err
|
||||||
|
if !idempotent(method) {
|
||||||
|
return lastErr
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if resp.StatusCode >= 200 && resp.StatusCode < 300 {
|
||||||
|
return decodeSuccess(respBody, result)
|
||||||
|
}
|
||||||
|
lastErr = apiError(resp.StatusCode, respBody)
|
||||||
|
if !retryable(resp.StatusCode) || !idempotent(method) {
|
||||||
|
return lastErr
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return lastErr
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) doGet(path string, result interface{}) error {
|
||||||
|
return c.doRequest(http.MethodGet, path, nil, result)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) doPost(path string, body interface{}, result interface{}) error {
|
||||||
|
return c.doRequest(http.MethodPost, path, body, result)
|
||||||
|
}
|
||||||
|
|
||||||
|
// publicRequest sends one unauthenticated request and decodes accepted statuses.
|
||||||
|
func publicRequest(method, url string, body interface{}, result interface{}, accepted ...int) error {
|
||||||
|
data, err := marshalBody(body)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
req, err := newRequest(method, url, data, "", "application/json")
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
resp, err := (&http.Client{Timeout: requestTimeout}).Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("elydora: http request: %w", err)
|
||||||
|
}
|
||||||
|
respBody, err := readBody(resp, maxResponseBytes)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
for _, status := range accepted {
|
||||||
|
if resp.StatusCode == status {
|
||||||
|
return decodeSuccess(respBody, result)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(accepted) == 0 && resp.StatusCode >= 200 && resp.StatusCode < 300 {
|
||||||
|
return decodeSuccess(respBody, result)
|
||||||
|
}
|
||||||
|
return apiError(resp.StatusCode, respBody)
|
||||||
|
}
|
||||||
@@ -119,9 +119,6 @@ func readSecretFileWithOpen(
|
|||||||
if err := validateSecretFile(before, path, label); err != nil {
|
if err := validateSecretFile(before, path, label); err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
if !os.SameFile(before, before) {
|
|
||||||
return "", fmt.Errorf("establish %s file identity at %s", label, path)
|
|
||||||
}
|
|
||||||
|
|
||||||
file, err := openFile(path)
|
file, err := openFile(path)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -65,6 +65,7 @@ func prepareClineCLIEnvironment(t *testing.T) (string, string, string, []string)
|
|||||||
t.Helper()
|
t.Helper()
|
||||||
root := t.TempDir()
|
root := t.TempDir()
|
||||||
homeDir := filepath.Join(root, "home")
|
homeDir := filepath.Join(root, "home")
|
||||||
|
preparePrivateCLITestHome(t, homeDir)
|
||||||
clineDir := filepath.Join(root, "cline-home")
|
clineDir := filepath.Join(root, "cline-home")
|
||||||
workspace := filepath.Join(root, "workspace")
|
workspace := filepath.Join(root, "workspace")
|
||||||
if err := os.MkdirAll(workspace, 0700); err != nil {
|
if err := os.MkdirAll(workspace, 0700); err != nil {
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ func prepareDroidCLIEnvironment(t *testing.T) (string, string, []string) {
|
|||||||
t.Helper()
|
t.Helper()
|
||||||
root := t.TempDir()
|
root := t.TempDir()
|
||||||
homeDir := filepath.Join(root, "home with spaces and 'quote %DROID%")
|
homeDir := filepath.Join(root, "home with spaces and 'quote %DROID%")
|
||||||
|
preparePrivateCLITestHome(t, homeDir)
|
||||||
workspace := filepath.Join(root, "workspace with spaces")
|
workspace := filepath.Join(root, "workspace with spaces")
|
||||||
if err := os.MkdirAll(filepath.Join(workspace, ".git"), 0700); err != nil {
|
if err := os.MkdirAll(filepath.Join(workspace, ".git"), 0700); err != nil {
|
||||||
t.Fatalf("create Factory Droid CLI workspace: %v", err)
|
t.Fatalf("create Factory Droid CLI workspace: %v", err)
|
||||||
|
|||||||
@@ -8,10 +8,10 @@ import (
|
|||||||
"sort"
|
"sort"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"github.com/Elydora-Infrastructure/Elydora-Go-SDK/cmd/elydora/plugins"
|
"github.com/Elydora-Infrastructure/Elydora-Go-SDK/v2/cmd/elydora/plugins"
|
||||||
)
|
)
|
||||||
|
|
||||||
const version = "1.2.1"
|
const version = "2.1.1"
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
if len(os.Args) < 2 {
|
if len(os.Args) < 2 {
|
||||||
@@ -57,10 +57,6 @@ Run "elydora <command> -h" for details on each command.
|
|||||||
`)
|
`)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// install
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
func cmdInstall(args []string) {
|
func cmdInstall(args []string) {
|
||||||
if err := rejectLegacySecretArguments(args); err != nil {
|
if err := rejectLegacySecretArguments(args); err != nil {
|
||||||
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
|
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
|
||||||
@@ -160,10 +156,6 @@ func cmdInstall(args []string) {
|
|||||||
fmt.Printf(" Guard script: %s\n", guardScriptPath)
|
fmt.Printf(" Guard script: %s\n", guardScriptPath)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// uninstall
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
func cmdUninstall(args []string) {
|
func cmdUninstall(args []string) {
|
||||||
fs := flag.NewFlagSet("uninstall", flag.ExitOnError)
|
fs := flag.NewFlagSet("uninstall", flag.ExitOnError)
|
||||||
agent := fs.String("agent", "", "Agent name (required)")
|
agent := fs.String("agent", "", "Agent name (required)")
|
||||||
@@ -209,10 +201,6 @@ func cmdUninstall(args []string) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// status
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
func cmdStatus(args []string) {
|
func cmdStatus(args []string) {
|
||||||
fs := flag.NewFlagSet("status", flag.ExitOnError)
|
fs := flag.NewFlagSet("status", flag.ExitOnError)
|
||||||
agent := fs.String("agent", "", "Agent name (optional — omit to show all)")
|
agent := fs.String("agent", "", "Agent name (optional — omit to show all)")
|
||||||
@@ -256,10 +244,6 @@ func showAgentStatus(name string) {
|
|||||||
fmt.Printf(" %-14s %-20s %s\n", name, status.DisplayName, state)
|
fmt.Printf(" %-14s %-20s %s\n", name, status.DisplayName, state)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// agents
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
func cmdAgents() {
|
func cmdAgents() {
|
||||||
fmt.Println("Supported agents:")
|
fmt.Println("Supported agents:")
|
||||||
fmt.Println()
|
fmt.Println()
|
||||||
@@ -282,12 +266,3 @@ func sortedAgentNames() []string {
|
|||||||
sort.Strings(names)
|
sort.Strings(names)
|
||||||
return names
|
return names
|
||||||
}
|
}
|
||||||
|
|
||||||
// guardScriptPathForAgent returns the path to ~/.elydora/<agentId>/guard.js.
|
|
||||||
func guardScriptPathForAgent(agentId string) (string, error) {
|
|
||||||
agentDirectory, err := plugins.ResolveAgentRuntimeDirectory(agentId)
|
|
||||||
if err != nil {
|
|
||||||
return "", err
|
|
||||||
}
|
|
||||||
return filepath.Join(agentDirectory, "guard.js"), nil
|
|
||||||
}
|
|
||||||
@@ -5,6 +5,8 @@ import (
|
|||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
|
"github.com/Elydora-Infrastructure/Elydora-Go-SDK/v2/cmd/elydora/plugins"
|
||||||
)
|
)
|
||||||
|
|
||||||
func setMainTestHome(t *testing.T) string {
|
func setMainTestHome(t *testing.T) string {
|
||||||
@@ -17,9 +19,9 @@ func setMainTestHome(t *testing.T) string {
|
|||||||
|
|
||||||
func TestGuardScriptPathRejectsUnsafeAgentID(t *testing.T) {
|
func TestGuardScriptPathRejectsUnsafeAgentID(t *testing.T) {
|
||||||
setMainTestHome(t)
|
setMainTestHome(t)
|
||||||
_, err := guardScriptPathForAgent("../escape")
|
_, err := plugins.ResolveAgentRuntimeDirectory("../escape")
|
||||||
if err == nil || !strings.Contains(err.Error(), "invalid agent ID") {
|
if err == nil || !strings.Contains(err.Error(), "invalid agent ID") {
|
||||||
t.Fatalf("guardScriptPathForAgent() error = %v", err)
|
t.Fatalf("ResolveAgentRuntimeDirectory() error = %v", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -10,8 +10,7 @@ type AugmentPlugin struct {
|
|||||||
rename renameFunc
|
rename renameFunc
|
||||||
}
|
}
|
||||||
|
|
||||||
// ManagesGuardRuntime reports that Auggie commits its provider guard together
|
// ManagesGuardRuntime reports that the guard is committed with the audit runtime.
|
||||||
// with both wrappers, the audit runtime, and user settings.
|
|
||||||
func (p *AugmentPlugin) ManagesGuardRuntime() bool {
|
func (p *AugmentPlugin) ManagesGuardRuntime() bool {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
@@ -57,13 +56,14 @@ func (p *AugmentPlugin) Install(config InstallConfig) error {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if err := writeAugmentChanges(
|
if err := writeManagedChanges(
|
||||||
changes,
|
changes,
|
||||||
"Install Augment Code CLI hooks",
|
"Install Augment Code CLI hooks",
|
||||||
p.rename,
|
p.rename,
|
||||||
paths.runtimeRoot,
|
paths.runtimeRoot,
|
||||||
paths.agentDirectory,
|
paths.agentDirectory,
|
||||||
filepath.Dir(document.configPath),
|
filepath.Dir(document.configPath),
|
||||||
|
"Auggie configuration directory",
|
||||||
); err != nil {
|
); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -99,13 +99,14 @@ func (p *AugmentPlugin) Uninstall(agentID string) error {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
return writeAugmentChanges(
|
return writeManagedChanges(
|
||||||
[]*fileChange{change},
|
[]*fileChange{change},
|
||||||
"Uninstall Augment Code CLI hooks",
|
"Uninstall Augment Code CLI hooks",
|
||||||
p.rename,
|
p.rename,
|
||||||
"",
|
"",
|
||||||
"",
|
"",
|
||||||
filepath.Dir(document.configPath),
|
filepath.Dir(document.configPath),
|
||||||
|
"Auggie configuration directory",
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -16,7 +16,6 @@ const (
|
|||||||
augmentGuardScript = "guard.js"
|
augmentGuardScript = "guard.js"
|
||||||
augmentAuditScript = "hook.js"
|
augmentAuditScript = "hook.js"
|
||||||
augmentHookTimeout = float64(10_000)
|
augmentHookTimeout = float64(10_000)
|
||||||
augmentPOSIXApostrophe = `'"'"'`
|
|
||||||
)
|
)
|
||||||
|
|
||||||
var (
|
var (
|
||||||
@@ -68,14 +67,6 @@ type augmentRuntimeContract struct {
|
|||||||
auditWrapper string
|
auditWrapper string
|
||||||
}
|
}
|
||||||
|
|
||||||
func cloneAugmentObject(value map[string]any) map[string]any {
|
|
||||||
clone := make(map[string]any, len(value))
|
|
||||||
for key, item := range value {
|
|
||||||
clone[key] = item
|
|
||||||
}
|
|
||||||
return clone
|
|
||||||
}
|
|
||||||
|
|
||||||
func validateAugmentHandler(
|
func validateAugmentHandler(
|
||||||
value any,
|
value any,
|
||||||
event string,
|
event string,
|
||||||
@@ -114,7 +105,7 @@ func validateAugmentHandler(
|
|||||||
return nil, fmt.Errorf("%s timeout must be a positive finite number", label)
|
return nil, fmt.Errorf("%s timeout must be a positive finite number", label)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return cloneAugmentObject(handler), nil
|
return cloneJSONObject(handler), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func validateAugmentMetadata(value any, label string) error {
|
func validateAugmentMetadata(value any, label string) error {
|
||||||
@@ -169,7 +160,7 @@ func validateAugmentGroup(value any, event string, groupIndex int) (augmentGroup
|
|||||||
}
|
}
|
||||||
handlers = append(handlers, handler)
|
handlers = append(handlers, handler)
|
||||||
}
|
}
|
||||||
return augmentGroup{object: cloneAugmentObject(object), handlers: handlers}, nil
|
return augmentGroup{object: cloneJSONObject(object), handlers: handlers}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func readAugmentHooks(root map[string]any) (augmentHooks, error) {
|
func readAugmentHooks(root map[string]any) (augmentHooks, error) {
|
||||||
@@ -248,7 +239,7 @@ func renderAugmentDocument(
|
|||||||
if !document.exists && len(hooks) == 0 {
|
if !document.exists && len(hooks) == 0 {
|
||||||
return &augmentRenderedDocument{document: document}, nil
|
return &augmentRenderedDocument{document: document}, nil
|
||||||
}
|
}
|
||||||
root := cloneAugmentObject(document.root)
|
root := cloneJSONObject(document.root)
|
||||||
if len(hooks) == 0 {
|
if len(hooks) == 0 {
|
||||||
delete(root, "hooks")
|
delete(root, "hooks")
|
||||||
} else {
|
} else {
|
||||||
@@ -281,7 +272,7 @@ func renderAugmentHooks(hooks augmentHooks) map[string]any {
|
|||||||
for event, groups := range hooks {
|
for event, groups := range hooks {
|
||||||
values := make([]any, 0, len(groups))
|
values := make([]any, 0, len(groups))
|
||||||
for _, group := range groups {
|
for _, group := range groups {
|
||||||
object := cloneAugmentObject(group.object)
|
object := cloneJSONObject(group.object)
|
||||||
handlers := make([]any, 0, len(group.handlers))
|
handlers := make([]any, 0, len(group.handlers))
|
||||||
for _, handler := range group.handlers {
|
for _, handler := range group.handlers {
|
||||||
handlers = append(handlers, handler)
|
handlers = append(handlers, handler)
|
||||||
@@ -314,23 +305,8 @@ func readAugmentWindowsArgument(command string) (string, bool) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func readAugmentPOSIXArgument(command string) (string, bool) {
|
func readAugmentPOSIXArgument(command string) (string, bool) {
|
||||||
if len(command) < 2 || command[0] != '\'' {
|
value, end, ok := readPOSIXArgument(command, 0)
|
||||||
return "", false
|
return value, ok && end == len(command) && value != ""
|
||||||
}
|
|
||||||
var value strings.Builder
|
|
||||||
for index := 1; index < len(command); {
|
|
||||||
if strings.HasPrefix(command[index:], augmentPOSIXApostrophe) {
|
|
||||||
value.WriteByte('\'')
|
|
||||||
index += len(augmentPOSIXApostrophe)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if command[index] == '\'' {
|
|
||||||
return value.String(), index == len(command)-1 && value.Len() > 0
|
|
||||||
}
|
|
||||||
value.WriteByte(command[index])
|
|
||||||
index++
|
|
||||||
}
|
|
||||||
return "", false
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func parseAugmentCommand(command string) (string, bool) {
|
func parseAugmentCommand(command string) (string, bool) {
|
||||||
@@ -340,36 +316,6 @@ func parseAugmentCommand(command string) (string, bool) {
|
|||||||
return readAugmentPOSIXArgument(command)
|
return readAugmentPOSIXArgument(command)
|
||||||
}
|
}
|
||||||
|
|
||||||
func normalizeAugmentPath(value string) string {
|
|
||||||
absolute, err := filepath.Abs(value)
|
|
||||||
if err == nil {
|
|
||||||
value = absolute
|
|
||||||
}
|
|
||||||
value = filepath.ToSlash(filepath.Clean(value))
|
|
||||||
if runtime.GOOS == "windows" {
|
|
||||||
return strings.ToLower(value)
|
|
||||||
}
|
|
||||||
return value
|
|
||||||
}
|
|
||||||
|
|
||||||
func sameAugmentPath(left, right string) bool {
|
|
||||||
return normalizeAugmentPath(left) == normalizeAugmentPath(right)
|
|
||||||
}
|
|
||||||
|
|
||||||
func sameAugmentAgentID(left, right string) bool {
|
|
||||||
if runtime.GOOS == "windows" {
|
|
||||||
return strings.EqualFold(left, right)
|
|
||||||
}
|
|
||||||
return left == right
|
|
||||||
}
|
|
||||||
|
|
||||||
func sameAugmentFileName(left, right string) bool {
|
|
||||||
if runtime.GOOS == "windows" {
|
|
||||||
return strings.EqualFold(left, right)
|
|
||||||
}
|
|
||||||
return left == right
|
|
||||||
}
|
|
||||||
|
|
||||||
func managedAugmentAgentID(
|
func managedAugmentAgentID(
|
||||||
handler map[string]any,
|
handler map[string]any,
|
||||||
wrapperName, runtimeRoot string,
|
wrapperName, runtimeRoot string,
|
||||||
@@ -385,11 +331,11 @@ func managedAugmentAgentID(
|
|||||||
return "", false
|
return "", false
|
||||||
}
|
}
|
||||||
wrapperPath, ok := parseAugmentCommand(command)
|
wrapperPath, ok := parseAugmentCommand(command)
|
||||||
if !ok || !sameAugmentFileName(filepath.Base(wrapperPath), wrapperName) {
|
if !ok || !sameManagedName(filepath.Base(wrapperPath), wrapperName) {
|
||||||
return "", false
|
return "", false
|
||||||
}
|
}
|
||||||
agentDirectory := filepath.Dir(wrapperPath)
|
agentDirectory := filepath.Dir(wrapperPath)
|
||||||
if !sameAugmentPath(filepath.Dir(agentDirectory), runtimeRoot) {
|
if !sameManagedPath(filepath.Dir(agentDirectory), runtimeRoot) {
|
||||||
return "", false
|
return "", false
|
||||||
}
|
}
|
||||||
agentID := filepath.Base(agentDirectory)
|
agentID := filepath.Base(agentDirectory)
|
||||||
@@ -407,7 +353,7 @@ func removeManagedAugmentGroups(
|
|||||||
groupChanged := false
|
groupChanged := false
|
||||||
for _, handler := range group.handlers {
|
for _, handler := range group.handlers {
|
||||||
managedID, managed := managedAugmentAgentID(handler, wrapperName, runtimeRoot)
|
managedID, managed := managedAugmentAgentID(handler, wrapperName, runtimeRoot)
|
||||||
remove := managed && (agentID == "" || sameAugmentAgentID(managedID, agentID))
|
remove := managed && (agentID == "" || sameManagedAgentID(managedID, agentID))
|
||||||
if remove {
|
if remove {
|
||||||
changed = true
|
changed = true
|
||||||
groupChanged = true
|
groupChanged = true
|
||||||
|
|||||||
@@ -1,96 +1,42 @@
|
|||||||
package plugins
|
package plugins
|
||||||
|
|
||||||
import (
|
import "fmt"
|
||||||
"encoding/json"
|
|
||||||
"fmt"
|
|
||||||
"os"
|
|
||||||
"path/filepath"
|
|
||||||
"strings"
|
|
||||||
)
|
|
||||||
|
|
||||||
type augmentRuntimePaths struct {
|
type augmentRuntimePaths struct {
|
||||||
runtimeRoot string
|
managedRuntimePaths
|
||||||
agentDirectory string
|
|
||||||
configPath string
|
|
||||||
keyPath string
|
|
||||||
guardPath string
|
|
||||||
auditPath string
|
|
||||||
guardWrapperPath string
|
guardWrapperPath string
|
||||||
auditWrapperPath string
|
auditWrapperPath string
|
||||||
}
|
}
|
||||||
|
|
||||||
func validateAugmentInstallConfig(config InstallConfig) error {
|
func augmentWrapperArtifacts(agentDirectory string) []managedArtifact {
|
||||||
for _, field := range []struct{ name, value string }{
|
wrappers := resolveAugmentWrapperPaths(agentDirectory)
|
||||||
{"agent name", config.AgentName},
|
return []managedArtifact{
|
||||||
{"organization ID", config.OrgID},
|
{wrappers.guard, "Auggie guard wrapper", maxManagedSourceBytes},
|
||||||
{"agent ID", config.AgentID},
|
{wrappers.audit, "Auggie audit wrapper", maxManagedSourceBytes},
|
||||||
{"key ID", config.KID},
|
|
||||||
{"private key", config.PrivateKey},
|
|
||||||
{"base URL", config.BaseURL},
|
|
||||||
{"guard script path", config.GuardScriptPath},
|
|
||||||
} {
|
|
||||||
if field.value == "" {
|
|
||||||
return fmt.Errorf("%s is required", field.name)
|
|
||||||
}
|
}
|
||||||
}
|
|
||||||
if config.AgentName != augmentAgentKey {
|
|
||||||
return fmt.Errorf(
|
|
||||||
"Augment Code CLI installation requires agent name %s",
|
|
||||||
augmentAgentKey,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
if strings.TrimSpace(config.OrgID) == "" {
|
|
||||||
return fmt.Errorf("organization ID is required")
|
|
||||||
}
|
|
||||||
if strings.TrimSpace(config.KID) == "" {
|
|
||||||
return fmt.Errorf("key ID is required")
|
|
||||||
}
|
|
||||||
if config.Token != "" && strings.TrimSpace(config.Token) == "" {
|
|
||||||
return fmt.Errorf("token must contain a non-whitespace value when provided")
|
|
||||||
}
|
|
||||||
if err := validateManagedPrivateKey(config.PrivateKey); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
return validateManagedBaseURL(config.BaseURL)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func augmentAgentPaths(config InstallConfig) (*augmentRuntimePaths, error) {
|
func augmentAgentPaths(config InstallConfig) (*augmentRuntimePaths, error) {
|
||||||
if err := validateAugmentInstallConfig(config); err != nil {
|
if err := validateManagedInstallConfig(config, augmentAgentKey, "Augment Code CLI"); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
runtimeRoot, err := AgentRuntimeRoot()
|
paths, err := resolveManagedRuntimePaths(config, augmentGuardScript, augmentAuditScript)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
agentDirectory, err := ResolveAgentRuntimeDirectory(config.AgentID)
|
wrappers := resolveAugmentWrapperPaths(paths.agentDirectory)
|
||||||
if err != nil {
|
return &augmentRuntimePaths{
|
||||||
return nil, err
|
managedRuntimePaths: *paths,
|
||||||
}
|
|
||||||
wrappers := resolveAugmentWrapperPaths(agentDirectory)
|
|
||||||
paths := &augmentRuntimePaths{
|
|
||||||
runtimeRoot: runtimeRoot, agentDirectory: agentDirectory,
|
|
||||||
configPath: filepath.Join(agentDirectory, "config.json"),
|
|
||||||
keyPath: filepath.Join(agentDirectory, "private.key"),
|
|
||||||
guardPath: filepath.Join(agentDirectory, augmentGuardScript),
|
|
||||||
auditPath: filepath.Join(agentDirectory, augmentAuditScript),
|
|
||||||
guardWrapperPath: wrappers.guard,
|
guardWrapperPath: wrappers.guard,
|
||||||
auditWrapperPath: wrappers.audit,
|
auditWrapperPath: wrappers.audit,
|
||||||
}
|
}, nil
|
||||||
if !filepath.IsAbs(config.GuardScriptPath) ||
|
}
|
||||||
!sameAugmentPath(config.GuardScriptPath, paths.guardPath) {
|
|
||||||
return nil, fmt.Errorf(
|
func validateAugmentRuntimeIdentity(agentDirectory, agentID string) error {
|
||||||
"Elydora guard runtime must use the managed agent directory: %s",
|
return validateManagedRuntimeIdentity(
|
||||||
paths.guardPath,
|
agentDirectory, agentID, augmentAgentKey, "Auggie",
|
||||||
|
augmentWrapperArtifacts(agentDirectory)...,
|
||||||
)
|
)
|
||||||
}
|
|
||||||
if config.HookScript != "" && (!filepath.IsAbs(config.HookScript) ||
|
|
||||||
!sameAugmentPath(config.HookScript, paths.auditPath)) {
|
|
||||||
return nil, fmt.Errorf(
|
|
||||||
"Elydora audit runtime must use the managed agent directory: %s",
|
|
||||||
paths.auditPath,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
return paths, nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func preflightAugmentInstallation(
|
func preflightAugmentInstallation(
|
||||||
@@ -98,55 +44,25 @@ func preflightAugmentInstallation(
|
|||||||
document *augmentDocument,
|
document *augmentDocument,
|
||||||
) (*augmentRuntimePaths, string, error) {
|
) (*augmentRuntimePaths, string, error) {
|
||||||
if document == nil || document.configPath == "" {
|
if document == nil || document.configPath == "" {
|
||||||
return nil, "", fmt.Errorf(
|
return nil, "", fmt.Errorf("Augment Code CLI installation requires a user settings document")
|
||||||
"Augment Code CLI installation requires a user settings document",
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
paths, err := augmentAgentPaths(config)
|
paths, err := augmentAgentPaths(config)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, "", err
|
return nil, "", err
|
||||||
}
|
}
|
||||||
if err := validateAugmentRuntimeIdentity(
|
if err := validateAugmentRuntimeIdentity(paths.agentDirectory, config.AgentID); err != nil {
|
||||||
paths.agentDirectory,
|
|
||||||
config.AgentID,
|
|
||||||
); err != nil {
|
|
||||||
return nil, "", err
|
return nil, "", err
|
||||||
}
|
}
|
||||||
nodePath, err := resolveNodeRuntime()
|
nodePath, err := resolveAbsoluteNodeRuntime("Auggie")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, "", err
|
return nil, "", err
|
||||||
}
|
}
|
||||||
if err := requireAugmentAbsoluteNode(nodePath); err != nil {
|
|
||||||
return nil, "", err
|
|
||||||
}
|
|
||||||
if err := validateAugmentMatchers(document.hooks, nodePath); err != nil {
|
if err := validateAugmentMatchers(document.hooks, nodePath); err != nil {
|
||||||
return nil, "", err
|
return nil, "", err
|
||||||
}
|
}
|
||||||
return paths, nodePath, nil
|
return paths, nodePath, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func buildAugmentRuntimeConfig(config InstallConfig) ([]byte, error) {
|
|
||||||
value := map[string]any{
|
|
||||||
"org_id": config.OrgID, "agent_id": config.AgentID, "kid": config.KID,
|
|
||||||
"base_url": config.BaseURL, "agent_name": augmentAgentKey,
|
|
||||||
}
|
|
||||||
if config.Token != "" {
|
|
||||||
value["token"] = config.Token
|
|
||||||
}
|
|
||||||
encoded, err := json.MarshalIndent(value, "", " ")
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("encode Elydora runtime config: %w", err)
|
|
||||||
}
|
|
||||||
encoded = append(encoded, '\n')
|
|
||||||
if len(encoded) > maxRuntimeConfigBytes {
|
|
||||||
return nil, fmt.Errorf(
|
|
||||||
"Elydora runtime config exceeds %d bytes after JSON encoding",
|
|
||||||
maxRuntimeConfigBytes,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
return encoded, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func prepareAugmentInstallationChanges(
|
func prepareAugmentInstallationChanges(
|
||||||
config InstallConfig,
|
config InstallConfig,
|
||||||
paths *augmentRuntimePaths,
|
paths *augmentRuntimePaths,
|
||||||
@@ -156,63 +72,27 @@ func prepareAugmentInstallationChanges(
|
|||||||
if paths == nil || rendered == nil {
|
if paths == nil || rendered == nil {
|
||||||
return nil, fmt.Errorf("prepared Auggie installation is required")
|
return nil, fmt.Errorf("prepared Auggie installation is required")
|
||||||
}
|
}
|
||||||
if err := validateAugmentRuntimeIdentity(
|
if err := validateAugmentRuntimeIdentity(paths.agentDirectory, config.AgentID); err != nil {
|
||||||
paths.agentDirectory,
|
|
||||||
config.AgentID,
|
|
||||||
); err != nil {
|
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
runtimeConfig, err := buildAugmentRuntimeConfig(config)
|
changes, err := managedRuntimeFileChanges(
|
||||||
|
config,
|
||||||
|
&paths.managedRuntimePaths,
|
||||||
|
augmentAgentKey,
|
||||||
|
generateGuardScript(augmentAgentKey, config.AgentID, "", false, ""),
|
||||||
|
buildHookScriptWithOutput(augmentAgentKey, config.AgentID, "", false, true),
|
||||||
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
items := []struct {
|
for _, item := range []struct {
|
||||||
path, label string
|
path, label string
|
||||||
content []byte
|
content []byte
|
||||||
mode os.FileMode
|
|
||||||
}{
|
}{
|
||||||
{
|
{paths.guardWrapperPath, "Auggie guard wrapper", buildAugmentWrapper(nodePath, paths.guardPath)},
|
||||||
paths.guardPath,
|
{paths.auditWrapperPath, "Auggie audit wrapper", buildAugmentWrapper(nodePath, paths.auditPath)},
|
||||||
"Elydora guard runtime",
|
} {
|
||||||
[]byte(generateGuardScript(
|
change, err := prepareFileChange(item.path, item.label, item.content, 0700)
|
||||||
augmentAgentKey,
|
|
||||||
config.AgentID,
|
|
||||||
"",
|
|
||||||
false,
|
|
||||||
"",
|
|
||||||
)),
|
|
||||||
0700,
|
|
||||||
},
|
|
||||||
{paths.configPath, "Elydora runtime config", runtimeConfig, 0600},
|
|
||||||
{paths.keyPath, "Elydora private key", []byte(config.PrivateKey), 0600},
|
|
||||||
{
|
|
||||||
paths.auditPath,
|
|
||||||
"Elydora audit runtime",
|
|
||||||
[]byte(buildHookScriptWithOutput(
|
|
||||||
augmentAgentKey,
|
|
||||||
config.AgentID,
|
|
||||||
"",
|
|
||||||
false,
|
|
||||||
true,
|
|
||||||
)),
|
|
||||||
0700,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
paths.guardWrapperPath,
|
|
||||||
"Auggie guard wrapper",
|
|
||||||
buildAugmentWrapper(nodePath, paths.guardPath),
|
|
||||||
0700,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
paths.auditWrapperPath,
|
|
||||||
"Auggie audit wrapper",
|
|
||||||
buildAugmentWrapper(nodePath, paths.auditPath),
|
|
||||||
0700,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
changes := make([]*fileChange, 0, len(items)+1)
|
|
||||||
for _, item := range items {
|
|
||||||
change, err := prepareFileChange(item.path, item.label, item.content, item.mode)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -83,43 +83,6 @@ func prepareRenderedAugmentChange(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
func writeAugmentChanges(
|
|
||||||
changes []*fileChange,
|
|
||||||
label string,
|
|
||||||
rename renameFunc,
|
|
||||||
runtimeRoot string,
|
|
||||||
agentDirectory string,
|
|
||||||
settingsDirectory string,
|
|
||||||
) error {
|
|
||||||
hasChanges := false
|
|
||||||
for _, change := range changes {
|
|
||||||
if change != nil {
|
|
||||||
hasChanges = true
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if !hasChanges {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
if agentDirectory != "" {
|
|
||||||
if err := EnsurePrivateDirectory(runtimeRoot); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if err := EnsurePrivateDirectory(agentDirectory); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if settingsDirectory != "" {
|
|
||||||
if err := ensureManagedDirectory(
|
|
||||||
settingsDirectory,
|
|
||||||
"Auggie configuration directory",
|
|
||||||
); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return writeChanges(changes, label, rename)
|
|
||||||
}
|
|
||||||
|
|
||||||
func validateAugmentMatchers(hooks augmentHooks, nodePath string) error {
|
func validateAugmentMatchers(hooks augmentHooks, nodePath string) error {
|
||||||
events := make([]string, 0, len(hooks))
|
events := make([]string, 0, len(hooks))
|
||||||
for event := range hooks {
|
for event := range hooks {
|
||||||
@@ -209,10 +172,3 @@ func resolveAugmentWrapperPaths(agentDirectory string) augmentWrapperPaths {
|
|||||||
audit: filepath.Join(agentDirectory, augmentAuditWrapperName()),
|
audit: filepath.Join(agentDirectory, augmentAuditWrapperName()),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func requireAugmentAbsoluteNode(nodePath string) error {
|
|
||||||
if !filepath.IsAbs(nodePath) || !isClaudeNodeExecutable(nodePath) {
|
|
||||||
return fmt.Errorf("Auggie hooks require an absolute Node.js executable path")
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
@@ -2,11 +2,8 @@ package plugins
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
"fmt"
|
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"runtime"
|
|
||||||
"sort"
|
"sort"
|
||||||
"strings"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
func managedAugmentIDs(
|
func managedAugmentIDs(
|
||||||
@@ -17,19 +14,10 @@ func managedAugmentIDs(
|
|||||||
result := map[string]string{}
|
result := map[string]string{}
|
||||||
for _, group := range groups {
|
for _, group := range groups {
|
||||||
for _, handler := range group.handlers {
|
for _, handler := range group.handlers {
|
||||||
agentID, managed := managedAugmentAgentID(
|
agentID, managed := managedAugmentAgentID(handler, wrapperName, runtimeRoot)
|
||||||
handler,
|
if managed {
|
||||||
wrapperName,
|
result[managedReferenceKey(agentID)] = agentID
|
||||||
runtimeRoot,
|
|
||||||
)
|
|
||||||
if !managed {
|
|
||||||
continue
|
|
||||||
}
|
}
|
||||||
key := agentID
|
|
||||||
if runtime.GOOS == "windows" {
|
|
||||||
key = strings.ToLower(key)
|
|
||||||
}
|
|
||||||
result[key] = agentID
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return result
|
return result
|
||||||
@@ -39,16 +27,8 @@ func augmentRuntimeContracts(
|
|||||||
hooks augmentHooks,
|
hooks augmentHooks,
|
||||||
runtimeRoot string,
|
runtimeRoot string,
|
||||||
) []augmentRuntimeContract {
|
) []augmentRuntimeContract {
|
||||||
guards := managedAugmentIDs(
|
guards := managedAugmentIDs(hooks["PreToolUse"], augmentGuardWrapperName(), runtimeRoot)
|
||||||
hooks["PreToolUse"],
|
audits := managedAugmentIDs(hooks["PostToolUse"], augmentAuditWrapperName(), runtimeRoot)
|
||||||
augmentGuardWrapperName(),
|
|
||||||
runtimeRoot,
|
|
||||||
)
|
|
||||||
audits := managedAugmentIDs(
|
|
||||||
hooks["PostToolUse"],
|
|
||||||
augmentAuditWrapperName(),
|
|
||||||
runtimeRoot,
|
|
||||||
)
|
|
||||||
keys := make([]string, 0, len(guards))
|
keys := make([]string, 0, len(guards))
|
||||||
for key := range guards {
|
for key := range guards {
|
||||||
if _, exists := audits[key]; exists {
|
if _, exists := audits[key]; exists {
|
||||||
@@ -72,282 +52,47 @@ func augmentRuntimeContracts(
|
|||||||
return contracts
|
return contracts
|
||||||
}
|
}
|
||||||
|
|
||||||
func readAugmentRuntimeConfig(path string) (map[string]any, bool, error) {
|
func augmentWrappersMatch(contract augmentRuntimeContract, nodePath string) (bool, error) {
|
||||||
snapshot, err := readManagedFile(
|
wrappers := resolveAugmentWrapperPaths(filepath.Dir(contract.guardPath))
|
||||||
path,
|
if !sameManagedPath(contract.guardWrapper, wrappers.guard) ||
|
||||||
"Elydora runtime config",
|
!sameManagedPath(contract.auditWrapper, wrappers.audit) {
|
||||||
maxRuntimeConfigBytes,
|
return false, nil
|
||||||
)
|
|
||||||
if err != nil || snapshot == nil {
|
|
||||||
return nil, snapshot != nil, err
|
|
||||||
}
|
}
|
||||||
label := fmt.Sprintf("Elydora runtime config at %s", path)
|
|
||||||
config, err := decodeStrictJSONObject(snapshot.contents, label)
|
|
||||||
return config, true, err
|
|
||||||
}
|
|
||||||
|
|
||||||
func requireAugmentRuntimeString(
|
|
||||||
config map[string]any,
|
|
||||||
field string,
|
|
||||||
configPath string,
|
|
||||||
) (string, error) {
|
|
||||||
value, ok := config[field].(string)
|
|
||||||
if !ok || strings.TrimSpace(value) == "" {
|
|
||||||
return "", fmt.Errorf(
|
|
||||||
"Elydora runtime config %s is invalid: %s",
|
|
||||||
field,
|
|
||||||
configPath,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
return value, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func validateAugmentRuntimeConfig(
|
|
||||||
config map[string]any,
|
|
||||||
expectedAgentID string,
|
|
||||||
configPath string,
|
|
||||||
) error {
|
|
||||||
supported := stringSet(
|
|
||||||
"org_id",
|
|
||||||
"agent_id",
|
|
||||||
"kid",
|
|
||||||
"base_url",
|
|
||||||
"token",
|
|
||||||
"agent_name",
|
|
||||||
)
|
|
||||||
fields := make([]string, 0, len(config))
|
|
||||||
for field := range config {
|
|
||||||
fields = append(fields, field)
|
|
||||||
}
|
|
||||||
sort.Strings(fields)
|
|
||||||
for _, field := range fields {
|
|
||||||
if _, ok := supported[field]; !ok {
|
|
||||||
return fmt.Errorf(
|
|
||||||
`Elydora runtime config has unsupported field %q: %s`,
|
|
||||||
field,
|
|
||||||
configPath,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if _, err := requireAugmentRuntimeString(config, "org_id", configPath); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if _, err := requireAugmentRuntimeString(config, "kid", configPath); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
agentID, err := requireAugmentRuntimeString(config, "agent_id", configPath)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if !sameAugmentAgentID(agentID, expectedAgentID) ||
|
|
||||||
config["agent_name"] != augmentAgentKey {
|
|
||||||
return fmt.Errorf(
|
|
||||||
"Elydora runtime identity does not match Auggie hooks: %s",
|
|
||||||
configPath,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
if _, exists := config["token"]; exists {
|
|
||||||
if _, err := requireAugmentRuntimeString(config, "token", configPath); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
baseURL, err := requireAugmentRuntimeString(config, "base_url", configPath)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if err := validateManagedBaseURL(baseURL); err != nil {
|
|
||||||
return fmt.Errorf(
|
|
||||||
"Elydora runtime config base URL is invalid at %s: %w",
|
|
||||||
configPath,
|
|
||||||
err,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func validateAugmentRuntimeIdentity(agentDirectory, agentID string) error {
|
|
||||||
runtimeRoot := filepath.Dir(agentDirectory)
|
|
||||||
rootExists, err := managedPhysicalDirectoryExists(
|
|
||||||
runtimeRoot,
|
|
||||||
"Elydora runtime directory",
|
|
||||||
)
|
|
||||||
if err != nil || !rootExists {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
directoryExists, err := managedPhysicalDirectoryExists(
|
|
||||||
agentDirectory,
|
|
||||||
"Elydora agent runtime directory",
|
|
||||||
)
|
|
||||||
if err != nil || !directoryExists {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
configPath := filepath.Join(agentDirectory, "config.json")
|
|
||||||
config, configExists, err := readAugmentRuntimeConfig(configPath)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
artifactExists := false
|
|
||||||
for _, item := range []struct {
|
for _, item := range []struct {
|
||||||
path string
|
path, label string
|
||||||
label string
|
expected []byte
|
||||||
limit int64
|
|
||||||
}{
|
}{
|
||||||
{filepath.Join(agentDirectory, "private.key"), "Elydora private key", maxProtectedSecretBytes},
|
{contract.guardWrapper, "Auggie guard wrapper", buildAugmentWrapper(nodePath, contract.guardPath)},
|
||||||
{filepath.Join(agentDirectory, augmentGuardScript), "Elydora guard runtime", maxManagedSourceBytes},
|
{contract.auditWrapper, "Auggie audit wrapper", buildAugmentWrapper(nodePath, contract.auditPath)},
|
||||||
{filepath.Join(agentDirectory, augmentAuditScript), "Elydora audit runtime", maxManagedSourceBytes},
|
|
||||||
{filepath.Join(agentDirectory, augmentGuardWrapperName()), "Auggie guard wrapper", maxManagedSourceBytes},
|
|
||||||
{filepath.Join(agentDirectory, augmentAuditWrapperName()), "Auggie audit wrapper", maxManagedSourceBytes},
|
|
||||||
{filepath.Join(agentDirectory, "chain-state.json"), "Elydora chain state", maxRuntimeConfigBytes},
|
|
||||||
{filepath.Join(agentDirectory, "status-cache.json"), "Elydora status cache", maxRuntimeConfigBytes},
|
|
||||||
{filepath.Join(agentDirectory, "error.log"), "Elydora error log", maxManagedSourceBytes},
|
|
||||||
} {
|
} {
|
||||||
exists, inspectErr := managedPhysicalFileExists(
|
snapshot, err := readManagedFile(item.path, item.label, maxManagedSourceBytes)
|
||||||
item.path,
|
if err != nil || snapshot == nil || !bytes.Equal(snapshot.contents, item.expected) {
|
||||||
item.label,
|
|
||||||
item.limit,
|
|
||||||
)
|
|
||||||
if inspectErr != nil {
|
|
||||||
return inspectErr
|
|
||||||
}
|
|
||||||
artifactExists = artifactExists || exists
|
|
||||||
}
|
|
||||||
if !configExists {
|
|
||||||
if artifactExists {
|
|
||||||
return fmt.Errorf(
|
|
||||||
"Elydora runtime identity cannot be verified without config.json: %s",
|
|
||||||
agentDirectory,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
configuredID, ok := config["agent_id"].(string)
|
|
||||||
if !ok || config["agent_name"] != augmentAgentKey ||
|
|
||||||
!sameAugmentAgentID(configuredID, agentID) {
|
|
||||||
return fmt.Errorf(
|
|
||||||
"Elydora runtime config identity does not match Auggie agent %s: %s",
|
|
||||||
agentID,
|
|
||||||
configPath,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func augmentRuntimeContractExists(
|
|
||||||
contract augmentRuntimeContract,
|
|
||||||
runtimeRoot string,
|
|
||||||
nodePath string,
|
|
||||||
) (bool, error) {
|
|
||||||
agentDirectory := filepath.Dir(contract.guardPath)
|
|
||||||
wrappers := resolveAugmentWrapperPaths(agentDirectory)
|
|
||||||
if !sameAugmentPath(filepath.Dir(agentDirectory), runtimeRoot) ||
|
|
||||||
!sameAugmentPath(
|
|
||||||
contract.auditPath,
|
|
||||||
filepath.Join(agentDirectory, augmentAuditScript),
|
|
||||||
) ||
|
|
||||||
!sameAugmentPath(contract.guardWrapper, wrappers.guard) ||
|
|
||||||
!sameAugmentPath(contract.auditWrapper, wrappers.audit) {
|
|
||||||
return false, nil
|
|
||||||
}
|
|
||||||
rootExists, err := managedPhysicalDirectoryExists(
|
|
||||||
runtimeRoot,
|
|
||||||
"Elydora runtime directory",
|
|
||||||
)
|
|
||||||
if err != nil || !rootExists {
|
|
||||||
return false, err
|
return false, err
|
||||||
}
|
}
|
||||||
directoryExists, err := managedPhysicalDirectoryExists(
|
|
||||||
agentDirectory,
|
|
||||||
"Elydora agent runtime directory",
|
|
||||||
)
|
|
||||||
if err != nil || !directoryExists {
|
|
||||||
return false, err
|
|
||||||
}
|
}
|
||||||
configPath := filepath.Join(agentDirectory, "config.json")
|
return true, nil
|
||||||
keyPath := filepath.Join(agentDirectory, "private.key")
|
|
||||||
config, configExists, err := readAugmentRuntimeConfig(configPath)
|
|
||||||
if err != nil {
|
|
||||||
return false, err
|
|
||||||
}
|
|
||||||
key, err := readManagedFile(keyPath, "Elydora private key", maxProtectedSecretBytes)
|
|
||||||
if err != nil {
|
|
||||||
return false, err
|
|
||||||
}
|
|
||||||
guard, err := readManagedFile(
|
|
||||||
contract.guardPath,
|
|
||||||
"Elydora guard runtime",
|
|
||||||
maxManagedSourceBytes,
|
|
||||||
)
|
|
||||||
if err != nil {
|
|
||||||
return false, err
|
|
||||||
}
|
|
||||||
audit, err := readManagedFile(
|
|
||||||
contract.auditPath,
|
|
||||||
"Elydora audit runtime",
|
|
||||||
maxManagedSourceBytes,
|
|
||||||
)
|
|
||||||
if err != nil {
|
|
||||||
return false, err
|
|
||||||
}
|
|
||||||
guardWrapper, err := readManagedFile(
|
|
||||||
contract.guardWrapper,
|
|
||||||
"Auggie guard wrapper",
|
|
||||||
maxManagedSourceBytes,
|
|
||||||
)
|
|
||||||
if err != nil {
|
|
||||||
return false, err
|
|
||||||
}
|
|
||||||
auditWrapper, err := readManagedFile(
|
|
||||||
contract.auditWrapper,
|
|
||||||
"Auggie audit wrapper",
|
|
||||||
maxManagedSourceBytes,
|
|
||||||
)
|
|
||||||
if err != nil {
|
|
||||||
return false, err
|
|
||||||
}
|
|
||||||
if !configExists || key == nil || guard == nil || audit == nil ||
|
|
||||||
guardWrapper == nil || auditWrapper == nil {
|
|
||||||
return false, nil
|
|
||||||
}
|
|
||||||
if err := validateAugmentRuntimeConfig(
|
|
||||||
config,
|
|
||||||
contract.agentID,
|
|
||||||
configPath,
|
|
||||||
); err != nil {
|
|
||||||
return false, err
|
|
||||||
}
|
|
||||||
if err := validateManagedPrivateKey(string(key.contents)); err != nil {
|
|
||||||
return false, fmt.Errorf("Elydora private key at %s: %w", keyPath, err)
|
|
||||||
}
|
|
||||||
return len(guard.contents) > 0 &&
|
|
||||||
len(audit.contents) > 0 &&
|
|
||||||
bytes.Equal(
|
|
||||||
guardWrapper.contents,
|
|
||||||
buildAugmentWrapper(nodePath, contract.guardPath),
|
|
||||||
) &&
|
|
||||||
bytes.Equal(
|
|
||||||
auditWrapper.contents,
|
|
||||||
buildAugmentWrapper(nodePath, contract.auditPath),
|
|
||||||
), nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func augmentRuntimeFilesExist(
|
func augmentRuntimeFilesExist(
|
||||||
contracts []augmentRuntimeContract,
|
contracts []augmentRuntimeContract,
|
||||||
runtimeRoot string,
|
runtimeRoot string,
|
||||||
) (bool, error) {
|
) (bool, error) {
|
||||||
nodePath, err := resolveNodeRuntime()
|
nodePath, err := resolveAbsoluteNodeRuntime("Auggie")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return false, err
|
return false, err
|
||||||
}
|
}
|
||||||
if err := requireAugmentAbsoluteNode(nodePath); err != nil {
|
|
||||||
return false, err
|
|
||||||
}
|
|
||||||
for _, contract := range contracts {
|
for _, contract := range contracts {
|
||||||
exists, err := augmentRuntimeContractExists(contract, runtimeRoot, nodePath)
|
runtime := managedRuntimeContract{contract.agentID, contract.guardPath, contract.auditPath}
|
||||||
|
exists, err := managedRuntimeContractExists(runtime, augmentAgentKey, "Auggie", nil)
|
||||||
|
if err != nil || !exists {
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return false, err
|
return false, err
|
||||||
}
|
}
|
||||||
if exists {
|
continue
|
||||||
return true, nil
|
}
|
||||||
|
matches, err := augmentWrappersMatch(contract, nodePath)
|
||||||
|
if err != nil || matches {
|
||||||
|
return matches, err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return false, nil
|
return false, nil
|
||||||
|
|||||||
@@ -46,6 +46,7 @@ func prepareAugmentFixture(
|
|||||||
t.Helper()
|
t.Helper()
|
||||||
root := t.TempDir()
|
root := t.TempDir()
|
||||||
homeDir := filepath.Join(root, "home with spaces and 'quote")
|
homeDir := filepath.Join(root, "home with spaces and 'quote")
|
||||||
|
preparePrivateTransactionTestDirectory(t, homeDir)
|
||||||
projectDir := filepath.Join(root, "project with spaces")
|
projectDir := filepath.Join(root, "project with spaces")
|
||||||
if err := os.MkdirAll(projectDir, 0700); err != nil {
|
if err := os.MkdirAll(projectDir, 0700); err != nil {
|
||||||
t.Fatalf("create project directory: %v", err)
|
t.Fatalf("create project directory: %v", err)
|
||||||
|
|||||||
@@ -54,7 +54,7 @@ func TestAugmentInstallRollsBackAllSevenFiles(t *testing.T) {
|
|||||||
)
|
)
|
||||||
failed := false
|
failed := false
|
||||||
fixture.plugin.rename = func(source, destination string) error {
|
fixture.plugin.rename = func(source, destination string) error {
|
||||||
if sameAugmentPath(destination, fixture.configPath) &&
|
if sameManagedPath(destination, fixture.configPath) &&
|
||||||
strings.HasSuffix(source, ".tmp") &&
|
strings.HasSuffix(source, ".tmp") &&
|
||||||
!failed {
|
!failed {
|
||||||
failed = true
|
failed = true
|
||||||
@@ -87,13 +87,14 @@ func TestPreparedAugmentInstallRejectsConcurrentSettingsChange(t *testing.T) {
|
|||||||
if err := os.WriteFile(fixture.configPath, concurrent, 0600); err != nil {
|
if err := os.WriteFile(fixture.configPath, concurrent, 0600); err != nil {
|
||||||
t.Fatalf("write concurrent settings: %v", err)
|
t.Fatalf("write concurrent settings: %v", err)
|
||||||
}
|
}
|
||||||
err := writeAugmentChanges(
|
err := writeManagedChanges(
|
||||||
changes,
|
changes,
|
||||||
"Install Augment Code CLI hooks",
|
"Install Augment Code CLI hooks",
|
||||||
nil,
|
nil,
|
||||||
paths.runtimeRoot,
|
paths.runtimeRoot,
|
||||||
paths.agentDirectory,
|
paths.agentDirectory,
|
||||||
filepath.Dir(document.configPath),
|
filepath.Dir(document.configPath),
|
||||||
|
"Auggie configuration directory",
|
||||||
)
|
)
|
||||||
if err == nil || !strings.Contains(err.Error(), "changed during installation") {
|
if err == nil || !strings.Contains(err.Error(), "changed during installation") {
|
||||||
t.Fatalf("prepared install error = %v", err)
|
t.Fatalf("prepared install error = %v", err)
|
||||||
@@ -375,7 +376,7 @@ func TestAugmentUninstallPreservesSettingsAfterCommitFailure(t *testing.T) {
|
|||||||
t.Fatalf("read installed settings: %v", err)
|
t.Fatalf("read installed settings: %v", err)
|
||||||
}
|
}
|
||||||
fixture.plugin.rename = func(source, destination string) error {
|
fixture.plugin.rename = func(source, destination string) error {
|
||||||
if sameAugmentPath(destination, fixture.configPath) &&
|
if sameManagedPath(destination, fixture.configPath) &&
|
||||||
strings.HasSuffix(source, ".tmp") {
|
strings.HasSuffix(source, ".tmp") {
|
||||||
return errors.New("injected Auggie uninstall failure")
|
return errors.New("injected Auggie uninstall failure")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,8 +10,7 @@ type ClaudeCodePlugin struct {
|
|||||||
rename renameFunc
|
rename renameFunc
|
||||||
}
|
}
|
||||||
|
|
||||||
// ManagesGuardRuntime reports that Claude Code commits its provider guard with
|
// ManagesGuardRuntime reports that the guard is committed with the audit runtime.
|
||||||
// the audit runtime and user settings document.
|
|
||||||
func (p *ClaudeCodePlugin) ManagesGuardRuntime() bool {
|
func (p *ClaudeCodePlugin) ManagesGuardRuntime() bool {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
@@ -73,13 +72,14 @@ func (p *ClaudeCodePlugin) Install(config InstallConfig) error {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if err := writeClaudeChanges(
|
if err := writeManagedChanges(
|
||||||
changes,
|
changes,
|
||||||
"Install Claude Code hooks",
|
"Install Claude Code hooks",
|
||||||
p.rename,
|
p.rename,
|
||||||
paths.runtimeRoot,
|
paths.runtimeRoot,
|
||||||
paths.agentDirectory,
|
paths.agentDirectory,
|
||||||
filepath.Dir(document.filePath),
|
filepath.Dir(document.filePath),
|
||||||
|
"Claude Code configuration directory",
|
||||||
); err != nil {
|
); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -105,13 +105,14 @@ func (p *ClaudeCodePlugin) Uninstall(agentID string) error {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
return writeClaudeChanges(
|
return writeManagedChanges(
|
||||||
[]*fileChange{change},
|
[]*fileChange{change},
|
||||||
"Uninstall Claude Code hooks",
|
"Uninstall Claude Code hooks",
|
||||||
p.rename,
|
p.rename,
|
||||||
"",
|
"",
|
||||||
"",
|
"",
|
||||||
filepath.Dir(document.filePath),
|
filepath.Dir(document.filePath),
|
||||||
|
"Claude Code configuration directory",
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -77,19 +77,7 @@ type claudeRenderedDocument struct {
|
|||||||
remove bool
|
remove bool
|
||||||
}
|
}
|
||||||
|
|
||||||
type claudeRuntimeContract struct {
|
type claudeRuntimeContract = managedRuntimeContract
|
||||||
agentID string
|
|
||||||
guardPath string
|
|
||||||
auditPath string
|
|
||||||
}
|
|
||||||
|
|
||||||
func cloneClaudeObject(value map[string]any) map[string]any {
|
|
||||||
clone := make(map[string]any, len(value))
|
|
||||||
for key, item := range value {
|
|
||||||
clone[key] = item
|
|
||||||
}
|
|
||||||
return clone
|
|
||||||
}
|
|
||||||
|
|
||||||
func cloneClaudeHooks(source claudeHooks) claudeHooks {
|
func cloneClaudeHooks(source claudeHooks) claudeHooks {
|
||||||
clone := make(claudeHooks, len(source))
|
clone := make(claudeHooks, len(source))
|
||||||
@@ -299,7 +287,7 @@ func validateClaudeHandler(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return cloneClaudeObject(handler), nil
|
return cloneJSONObject(handler), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func validateClaudeGroup(value any, event string, groupIndex int) (claudeGroup, error) {
|
func validateClaudeGroup(value any, event string, groupIndex int) (claudeGroup, error) {
|
||||||
@@ -332,7 +320,7 @@ func validateClaudeGroup(value any, event string, groupIndex int) (claudeGroup,
|
|||||||
}
|
}
|
||||||
handlers = append(handlers, handler)
|
handlers = append(handlers, handler)
|
||||||
}
|
}
|
||||||
return claudeGroup{object: cloneClaudeObject(object), handlers: handlers}, nil
|
return claudeGroup{object: cloneJSONObject(object), handlers: handlers}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func readClaudeHooks(root map[string]any) (claudeHooks, error) {
|
func readClaudeHooks(root map[string]any) (claudeHooks, error) {
|
||||||
@@ -411,7 +399,7 @@ func renderClaudeHooks(hooks claudeHooks) map[string]any {
|
|||||||
for event, groups := range hooks {
|
for event, groups := range hooks {
|
||||||
values := make([]any, 0, len(groups))
|
values := make([]any, 0, len(groups))
|
||||||
for _, group := range groups {
|
for _, group := range groups {
|
||||||
object := cloneClaudeObject(group.object)
|
object := cloneJSONObject(group.object)
|
||||||
handlers := make([]any, 0, len(group.handlers))
|
handlers := make([]any, 0, len(group.handlers))
|
||||||
for _, handler := range group.handlers {
|
for _, handler := range group.handlers {
|
||||||
handlers = append(handlers, handler)
|
handlers = append(handlers, handler)
|
||||||
@@ -439,7 +427,7 @@ func renderClaudeDocument(
|
|||||||
document: document, changed: true, remove: true,
|
document: document, changed: true, remove: true,
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
root := cloneClaudeObject(document.root)
|
root := cloneJSONObject(document.root)
|
||||||
if len(hooks) == 0 {
|
if len(hooks) == 0 {
|
||||||
delete(root, "hooks")
|
delete(root, "hooks")
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -1,182 +1,48 @@
|
|||||||
package plugins
|
package plugins
|
||||||
|
|
||||||
import (
|
import "fmt"
|
||||||
"encoding/json"
|
|
||||||
"fmt"
|
|
||||||
"os"
|
|
||||||
"path/filepath"
|
|
||||||
"strings"
|
|
||||||
)
|
|
||||||
|
|
||||||
type claudeRuntimePaths struct {
|
|
||||||
runtimeRoot string
|
|
||||||
agentDirectory string
|
|
||||||
configPath string
|
|
||||||
keyPath string
|
|
||||||
guardPath string
|
|
||||||
auditPath string
|
|
||||||
}
|
|
||||||
|
|
||||||
func validateClaudeInstallConfig(config InstallConfig) error {
|
|
||||||
for _, field := range []struct{ name, value string }{
|
|
||||||
{"agent name", config.AgentName},
|
|
||||||
{"organization ID", config.OrgID},
|
|
||||||
{"agent ID", config.AgentID},
|
|
||||||
{"key ID", config.KID},
|
|
||||||
{"private key", config.PrivateKey},
|
|
||||||
{"base URL", config.BaseURL},
|
|
||||||
{"guard script path", config.GuardScriptPath},
|
|
||||||
} {
|
|
||||||
if field.value == "" {
|
|
||||||
return fmt.Errorf("%s is required", field.name)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if config.AgentName != claudeAgentKey {
|
|
||||||
return fmt.Errorf(
|
|
||||||
"Claude Code installation requires agent name %s",
|
|
||||||
claudeAgentKey,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
if strings.TrimSpace(config.OrgID) == "" {
|
|
||||||
return fmt.Errorf("organization ID is required")
|
|
||||||
}
|
|
||||||
if strings.TrimSpace(config.KID) == "" {
|
|
||||||
return fmt.Errorf("key ID is required")
|
|
||||||
}
|
|
||||||
if config.Token != "" && strings.TrimSpace(config.Token) == "" {
|
|
||||||
return fmt.Errorf("token must contain a non-whitespace value when provided")
|
|
||||||
}
|
|
||||||
if err := validateManagedPrivateKey(config.PrivateKey); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
return validateManagedBaseURL(config.BaseURL)
|
|
||||||
}
|
|
||||||
|
|
||||||
func claudeAgentPaths(config InstallConfig) (*claudeRuntimePaths, error) {
|
|
||||||
if err := validateClaudeInstallConfig(config); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
runtimeRoot, err := AgentRuntimeRoot()
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
agentDirectory, err := ResolveAgentRuntimeDirectory(config.AgentID)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
paths := &claudeRuntimePaths{
|
|
||||||
runtimeRoot: runtimeRoot, agentDirectory: agentDirectory,
|
|
||||||
configPath: filepath.Join(agentDirectory, "config.json"),
|
|
||||||
keyPath: filepath.Join(agentDirectory, "private.key"),
|
|
||||||
guardPath: filepath.Join(agentDirectory, claudeGuardScript),
|
|
||||||
auditPath: filepath.Join(agentDirectory, claudeAuditScript),
|
|
||||||
}
|
|
||||||
if !filepath.IsAbs(config.GuardScriptPath) ||
|
|
||||||
!sameClaudePath(config.GuardScriptPath, paths.guardPath) {
|
|
||||||
return nil, fmt.Errorf(
|
|
||||||
"guard runtime must use the managed Elydora agent directory: %s",
|
|
||||||
paths.guardPath,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
if config.HookScript != "" && (!filepath.IsAbs(config.HookScript) ||
|
|
||||||
!sameClaudePath(config.HookScript, paths.auditPath)) {
|
|
||||||
return nil, fmt.Errorf(
|
|
||||||
"audit runtime must use the managed Elydora agent directory: %s",
|
|
||||||
paths.auditPath,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
return paths, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func preflightClaudeInstallation(
|
func preflightClaudeInstallation(
|
||||||
config InstallConfig,
|
config InstallConfig,
|
||||||
document *claudeDocument,
|
document *claudeDocument,
|
||||||
) (*claudeRuntimePaths, string, error) {
|
) (*managedRuntimePaths, string, error) {
|
||||||
if document == nil || document.filePath == "" {
|
if document == nil || document.filePath == "" {
|
||||||
return nil, "", fmt.Errorf(
|
return nil, "", fmt.Errorf("Claude Code installation requires a user settings document")
|
||||||
"Claude Code installation requires a user settings document",
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
paths, err := claudeAgentPaths(config)
|
if err := validateManagedInstallConfig(config, claudeAgentKey, "Claude Code"); err != nil {
|
||||||
|
return nil, "", err
|
||||||
|
}
|
||||||
|
paths, err := resolveManagedRuntimePaths(config, claudeGuardScript, claudeAuditScript)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, "", err
|
return nil, "", err
|
||||||
}
|
}
|
||||||
if err := validateClaudeRuntimeIdentity(paths.agentDirectory, config.AgentID); err != nil {
|
if err := validateManagedRuntimeIdentity(
|
||||||
|
paths.agentDirectory, config.AgentID, claudeAgentKey, "Claude Code",
|
||||||
|
); err != nil {
|
||||||
return nil, "", err
|
return nil, "", err
|
||||||
}
|
}
|
||||||
nodePath, err := resolveNodeRuntime()
|
nodePath, err := resolveAbsoluteNodeRuntime("Claude Code")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, "", err
|
return nil, "", err
|
||||||
}
|
}
|
||||||
if err := requireClaudeAbsoluteNode(nodePath); err != nil {
|
|
||||||
return nil, "", err
|
|
||||||
}
|
|
||||||
return paths, nodePath, nil
|
return paths, nodePath, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func buildClaudeRuntimeConfig(config InstallConfig) ([]byte, error) {
|
|
||||||
value := map[string]any{
|
|
||||||
"org_id": config.OrgID, "agent_id": config.AgentID, "kid": config.KID,
|
|
||||||
"base_url": config.BaseURL, "agent_name": claudeAgentKey,
|
|
||||||
}
|
|
||||||
if config.Token != "" {
|
|
||||||
value["token"] = config.Token
|
|
||||||
}
|
|
||||||
encoded, err := json.MarshalIndent(value, "", " ")
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("encode Elydora runtime config: %w", err)
|
|
||||||
}
|
|
||||||
encoded = append(encoded, '\n')
|
|
||||||
if len(encoded) > maxRuntimeConfigBytes {
|
|
||||||
return nil, fmt.Errorf(
|
|
||||||
"elydora runtime config exceeds %d bytes after JSON encoding",
|
|
||||||
maxRuntimeConfigBytes,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
return encoded, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func prepareClaudeInstallationChanges(
|
func prepareClaudeInstallationChanges(
|
||||||
config InstallConfig,
|
config InstallConfig,
|
||||||
paths *claudeRuntimePaths,
|
paths *managedRuntimePaths,
|
||||||
rendered *claudeRenderedDocument,
|
rendered *claudeRenderedDocument,
|
||||||
) ([]*fileChange, error) {
|
) ([]*fileChange, error) {
|
||||||
runtimeConfig, err := buildClaudeRuntimeConfig(config)
|
changes, err := managedRuntimeFileChanges(
|
||||||
if err != nil {
|
config,
|
||||||
return nil, err
|
paths,
|
||||||
}
|
|
||||||
items := []struct {
|
|
||||||
path, label string
|
|
||||||
content []byte
|
|
||||||
mode os.FileMode
|
|
||||||
}{
|
|
||||||
{
|
|
||||||
paths.guardPath, "Elydora guard runtime",
|
|
||||||
[]byte(generateGuardScript(claudeAgentKey, config.AgentID, "", false, "")),
|
|
||||||
0700,
|
|
||||||
},
|
|
||||||
{paths.configPath, "Elydora runtime config", runtimeConfig, 0600},
|
|
||||||
{paths.keyPath, "Elydora private key", []byte(config.PrivateKey), 0600},
|
|
||||||
{
|
|
||||||
paths.auditPath, "Elydora audit runtime",
|
|
||||||
[]byte(buildHookScriptWithOutput(
|
|
||||||
claudeAgentKey,
|
claudeAgentKey,
|
||||||
config.AgentID,
|
generateGuardScript(claudeAgentKey, config.AgentID, "", false, ""),
|
||||||
"",
|
buildHookScriptWithOutput(claudeAgentKey, config.AgentID, "", false, true),
|
||||||
false,
|
)
|
||||||
true,
|
|
||||||
)),
|
|
||||||
0700,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
changes := make([]*fileChange, 0, len(items)+1)
|
|
||||||
for _, item := range items {
|
|
||||||
change, err := prepareFileChange(item.path, item.label, item.content, item.mode)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
changes = append(changes, change)
|
|
||||||
}
|
|
||||||
documentChange, err := prepareRenderedClaudeChange(rendered)
|
documentChange, err := prepareRenderedClaudeChange(rendered)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
|
|||||||
@@ -71,38 +71,3 @@ func prepareRenderedClaudeChange(
|
|||||||
rendered.remove,
|
rendered.remove,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
func writeClaudeChanges(
|
|
||||||
changes []*fileChange,
|
|
||||||
label string,
|
|
||||||
rename renameFunc,
|
|
||||||
runtimeRoot string,
|
|
||||||
agentDirectory string,
|
|
||||||
settingsDirectory string,
|
|
||||||
) error {
|
|
||||||
hasChanges := false
|
|
||||||
for _, change := range changes {
|
|
||||||
if change != nil {
|
|
||||||
hasChanges = true
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if !hasChanges {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
if agentDirectory != "" {
|
|
||||||
if err := EnsurePrivateDirectory(runtimeRoot); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if err := EnsurePrivateDirectory(agentDirectory); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if err := ensureManagedDirectory(
|
|
||||||
settingsDirectory,
|
|
||||||
"Claude Code configuration directory",
|
|
||||||
); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
return writeChanges(changes, label, rename)
|
|
||||||
}
|
|
||||||
@@ -1,73 +1,18 @@
|
|||||||
package plugins
|
package plugins
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"runtime"
|
|
||||||
"sort"
|
"sort"
|
||||||
"strings"
|
"strings"
|
||||||
)
|
)
|
||||||
|
|
||||||
type claudeRuntimeReference struct {
|
type claudeRuntimeReference = managedScriptReference
|
||||||
agentID string
|
|
||||||
scriptPath string
|
|
||||||
}
|
|
||||||
|
|
||||||
func sameClaudePath(left, right string) bool {
|
|
||||||
left = filepath.Clean(left)
|
|
||||||
right = filepath.Clean(right)
|
|
||||||
if absolute, err := filepath.Abs(left); err == nil {
|
|
||||||
left = absolute
|
|
||||||
}
|
|
||||||
if absolute, err := filepath.Abs(right); err == nil {
|
|
||||||
right = absolute
|
|
||||||
}
|
|
||||||
if runtime.GOOS == "windows" {
|
|
||||||
return strings.EqualFold(left, right)
|
|
||||||
}
|
|
||||||
return left == right
|
|
||||||
}
|
|
||||||
|
|
||||||
func sameClaudeAgentID(left, right string) bool {
|
|
||||||
if runtime.GOOS == "windows" {
|
|
||||||
return strings.EqualFold(left, right)
|
|
||||||
}
|
|
||||||
return left == right
|
|
||||||
}
|
|
||||||
|
|
||||||
func isClaudeNodeExecutable(path string) bool {
|
|
||||||
name := filepath.Base(path)
|
|
||||||
return name == "node" || strings.EqualFold(name, "node.exe")
|
|
||||||
}
|
|
||||||
|
|
||||||
func sameClaudeFileName(left, right string) bool {
|
|
||||||
if runtime.GOOS == "windows" {
|
|
||||||
return strings.EqualFold(left, right)
|
|
||||||
}
|
|
||||||
return left == right
|
|
||||||
}
|
|
||||||
|
|
||||||
func claudeRuntimeReferenceForScript(
|
func claudeRuntimeReferenceForScript(
|
||||||
scriptPath string,
|
scriptPath string,
|
||||||
scriptName string,
|
scriptName string,
|
||||||
) (*claudeRuntimeReference, error) {
|
) (*claudeRuntimeReference, error) {
|
||||||
if !filepath.IsAbs(scriptPath) ||
|
return resolveManagedScript(scriptPath, scriptName)
|
||||||
!sameClaudeFileName(filepath.Base(scriptPath), scriptName) {
|
|
||||||
return nil, nil
|
|
||||||
}
|
|
||||||
agentDirectory := filepath.Dir(scriptPath)
|
|
||||||
runtimeRoot, err := AgentRuntimeRoot()
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
if !sameClaudePath(filepath.Dir(agentDirectory), runtimeRoot) {
|
|
||||||
return nil, nil
|
|
||||||
}
|
|
||||||
agentID := filepath.Base(agentDirectory)
|
|
||||||
if agentID == "" || agentID == "." || agentID == ".." {
|
|
||||||
return nil, nil
|
|
||||||
}
|
|
||||||
return &claudeRuntimeReference{agentID: agentID, scriptPath: scriptPath}, nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func claudeLegacyReference(
|
func claudeLegacyReference(
|
||||||
@@ -95,7 +40,7 @@ func managedClaudeReference(
|
|||||||
handler["statusMessage"] == statusMessage {
|
handler["statusMessage"] == statusMessage {
|
||||||
nodePath, nodeOK := handler["command"].(string)
|
nodePath, nodeOK := handler["command"].(string)
|
||||||
args, argsOK := handler["args"].([]any)
|
args, argsOK := handler["args"].([]any)
|
||||||
if nodeOK && filepath.IsAbs(nodePath) && isClaudeNodeExecutable(nodePath) &&
|
if nodeOK && filepath.IsAbs(nodePath) && isNodeExecutable(nodePath) &&
|
||||||
argsOK && len(args) == 1 {
|
argsOK && len(args) == 1 {
|
||||||
if scriptPath, ok := args[0].(string); ok {
|
if scriptPath, ok := args[0].(string); ok {
|
||||||
return claudeRuntimeReferenceForScript(scriptPath, scriptName)
|
return claudeRuntimeReferenceForScript(scriptPath, scriptName)
|
||||||
@@ -146,7 +91,7 @@ func removeManagedClaudeGroups(
|
|||||||
return nil, false, err
|
return nil, false, err
|
||||||
}
|
}
|
||||||
owned := reference != nil &&
|
owned := reference != nil &&
|
||||||
(agentID == "" || sameClaudeAgentID(reference.agentID, agentID))
|
(agentID == "" || sameManagedAgentID(reference.agentID, agentID))
|
||||||
if owned {
|
if owned {
|
||||||
removed = true
|
removed = true
|
||||||
continue
|
continue
|
||||||
@@ -154,7 +99,7 @@ func removeManagedClaudeGroups(
|
|||||||
kept = append(kept, handler)
|
kept = append(kept, handler)
|
||||||
}
|
}
|
||||||
if len(kept) > 0 || !exactManagedClaudeGroup(group) {
|
if len(kept) > 0 || !exactManagedClaudeGroup(group) {
|
||||||
object := cloneClaudeObject(group.object)
|
object := cloneJSONObject(group.object)
|
||||||
object["hooks"] = group.object["hooks"]
|
object["hooks"] = group.object["hooks"]
|
||||||
result = append(result, claudeGroup{object: object, handlers: kept})
|
result = append(result, claudeGroup{object: object, handlers: kept})
|
||||||
}
|
}
|
||||||
@@ -235,13 +180,6 @@ func entirelyManagedClaudeDocument(document *claudeDocument) bool {
|
|||||||
return handlerCount > 0
|
return handlerCount > 0
|
||||||
}
|
}
|
||||||
|
|
||||||
func claudeReferenceKey(agentID string) string {
|
|
||||||
if runtime.GOOS == "windows" {
|
|
||||||
return strings.ToLower(agentID)
|
|
||||||
}
|
|
||||||
return agentID
|
|
||||||
}
|
|
||||||
|
|
||||||
func claudeReferencesForEvent(
|
func claudeReferencesForEvent(
|
||||||
groups []claudeGroup,
|
groups []claudeGroup,
|
||||||
scriptName string,
|
scriptName string,
|
||||||
@@ -263,7 +201,7 @@ func claudeReferencesForEvent(
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
if reference != nil {
|
if reference != nil {
|
||||||
key := claudeReferenceKey(reference.agentID)
|
key := managedReferenceKey(reference.agentID)
|
||||||
result[key] = append(result[key], *reference)
|
result[key] = append(result[key], *reference)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -307,7 +245,7 @@ func claudeRuntimeContracts(hooks claudeHooks) ([]claudeRuntimeContract, error)
|
|||||||
success := successes[key]
|
success := successes[key]
|
||||||
failure := failures[key]
|
failure := failures[key]
|
||||||
if len(guard) != 1 || len(success) != 1 || len(failure) != 1 ||
|
if len(guard) != 1 || len(success) != 1 || len(failure) != 1 ||
|
||||||
!sameClaudePath(success[0].scriptPath, failure[0].scriptPath) {
|
!sameManagedPath(success[0].scriptPath, failure[0].scriptPath) {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
contracts = append(contracts, claudeRuntimeContract{
|
contracts = append(contracts, claudeRuntimeContract{
|
||||||
@@ -317,10 +255,3 @@ func claudeRuntimeContracts(hooks claudeHooks) ([]claudeRuntimeContract, error)
|
|||||||
}
|
}
|
||||||
return contracts, nil
|
return contracts, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func requireClaudeAbsoluteNode(nodePath string) error {
|
|
||||||
if !filepath.IsAbs(nodePath) || !isClaudeNodeExecutable(nodePath) {
|
|
||||||
return fmt.Errorf("Claude Code hooks require an absolute Node.js executable path")
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,194 @@
|
|||||||
|
package plugins
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
type mismatchAPI struct {
|
||||||
|
mu sync.Mutex
|
||||||
|
rejections int
|
||||||
|
delay time.Duration
|
||||||
|
staleHead bool
|
||||||
|
staleLock bool
|
||||||
|
liveLock bool
|
||||||
|
statePath string
|
||||||
|
operations []map[string]any
|
||||||
|
}
|
||||||
|
|
||||||
|
const mismatchExpected = "Rxlf4j36C3KvIQ3hWuOkX698BR5iDypUFuB70JjEuvM"
|
||||||
|
|
||||||
|
func (api *mismatchAPI) ServeHTTP(response http.ResponseWriter, request *http.Request) {
|
||||||
|
api.mu.Lock()
|
||||||
|
defer api.mu.Unlock()
|
||||||
|
response.Header().Set("Content-Type", "application/json")
|
||||||
|
if request.Method != http.MethodPost || request.URL.Path != "/v1/operations" {
|
||||||
|
_ = json.NewEncoder(response).Encode(map[string]any{"agent": map[string]any{"status": "active"}})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var operation map[string]any
|
||||||
|
if err := json.NewDecoder(request.Body).Decode(&operation); err != nil {
|
||||||
|
http.Error(response, err.Error(), http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
api.operations = append(api.operations, operation)
|
||||||
|
count := len(api.operations)
|
||||||
|
if api.delay > 0 {
|
||||||
|
api.mu.Unlock()
|
||||||
|
time.Sleep(api.delay)
|
||||||
|
api.mu.Lock()
|
||||||
|
}
|
||||||
|
if api.statePath != "" {
|
||||||
|
_ = os.WriteFile(api.statePath, []byte(`{"prev_chain_hash":"`+strings.Repeat("B", 43)+`"}`), 0o600)
|
||||||
|
}
|
||||||
|
if count > api.rejections {
|
||||||
|
response.WriteHeader(http.StatusAccepted)
|
||||||
|
_, _ = fmt.Fprintf(response, `{"receipt":{"seq_no":%d}}`, count)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
expected := mismatchExpected
|
||||||
|
if api.rejections != 1 {
|
||||||
|
expected = strings.Repeat("A", 42) + fmt.Sprint(count)
|
||||||
|
}
|
||||||
|
response.WriteHeader(http.StatusBadRequest)
|
||||||
|
_, _ = fmt.Fprintf(
|
||||||
|
response,
|
||||||
|
`{"error":{"code":"PREV_HASH_MISMATCH","message":"Expected prev_chain_hash \"%s\", got \"x\"."}}`,
|
||||||
|
expected,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func runClaudeAuditAgainst(t *testing.T, api *mismatchAPI) (*claudeFixture, []map[string]any, time.Duration) {
|
||||||
|
t.Helper()
|
||||||
|
server := httptest.NewServer(api)
|
||||||
|
defer server.Close()
|
||||||
|
fixture := prepareClaudeFixture(t, claudeFixtureOptions{})
|
||||||
|
fixture.config.BaseURL = server.URL
|
||||||
|
fixture.config.Token = "ely_test_token"
|
||||||
|
if err := fixture.plugin.Install(fixture.config); err != nil {
|
||||||
|
t.Fatalf("install Claude hooks: %v", err)
|
||||||
|
}
|
||||||
|
settings := readClaudeTestObject(t, fixture.configPath)
|
||||||
|
audit := claudeTestManagedHandler(t, settings, "PostToolUse", claudeAuditScript, claudeAuditStatusMessage)
|
||||||
|
if api.staleHead {
|
||||||
|
api.mu.Lock()
|
||||||
|
api.statePath = filepath.Join(fixture.agentDir, "chain-state.json")
|
||||||
|
api.mu.Unlock()
|
||||||
|
}
|
||||||
|
if api.staleLock || api.liveLock {
|
||||||
|
lockPath := filepath.Join(fixture.agentDir, "chain-state.json.lock")
|
||||||
|
var owner []byte
|
||||||
|
if api.liveLock {
|
||||||
|
owner = []byte(fmt.Sprint(os.Getpid()))
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(lockPath, owner, 0o600); err != nil {
|
||||||
|
t.Fatalf("write stale lock: %v", err)
|
||||||
|
}
|
||||||
|
stale := time.Now().Add(-10 * time.Second)
|
||||||
|
if err := os.Chtimes(lockPath, stale, stale); err != nil {
|
||||||
|
t.Fatalf("age stale lock: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
started := time.Now()
|
||||||
|
exit, stdout, stderr := runClaudeHandler(t, audit, fixture, claudeOfficialPayload("PostToolUse"))
|
||||||
|
elapsed := time.Since(started)
|
||||||
|
if exit != 0 || stdout != "" || stderr != "" {
|
||||||
|
t.Fatalf("audit result = %d, %q, %q", exit, stdout, stderr)
|
||||||
|
}
|
||||||
|
api.mu.Lock()
|
||||||
|
defer api.mu.Unlock()
|
||||||
|
return fixture, api.operations, elapsed
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestClaudeAuditRetriesRejectedChainHash(t *testing.T) {
|
||||||
|
fixture, operations, _ := runClaudeAuditAgainst(t, &mismatchAPI{rejections: 1})
|
||||||
|
if len(operations) != 2 || operations[1]["prev_chain_hash"] != mismatchExpected {
|
||||||
|
t.Fatalf("operations = %#v", operations)
|
||||||
|
}
|
||||||
|
if operations[0]["operation_id"] == operations[1]["operation_id"] || operations[0]["nonce"] == operations[1]["nonce"] {
|
||||||
|
t.Fatalf("retry reused identifiers: %#v", operations)
|
||||||
|
}
|
||||||
|
state, err := os.ReadFile(filepath.Join(fixture.agentDir, "chain-state.json"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("read chain state: %v", err)
|
||||||
|
}
|
||||||
|
if !strings.Contains(string(state), fmt.Sprint(operations[1]["chain_hash"])) {
|
||||||
|
t.Fatalf("chain state %s does not hold %v", state, operations[1]["chain_hash"])
|
||||||
|
}
|
||||||
|
log, err := os.ReadFile(filepath.Join(fixture.agentDir, "error.log"))
|
||||||
|
if err != nil || !strings.Contains(string(log), "resynced to server: Rxlf") {
|
||||||
|
t.Fatalf("error log = %q, %v", log, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestClaudeAuditStopsAfterFiveRejections(t *testing.T) {
|
||||||
|
fixture, operations, _ := runClaudeAuditAgainst(t, &mismatchAPI{rejections: 99})
|
||||||
|
if len(operations) != 5 || operations[4]["prev_chain_hash"] != strings.Repeat("A", 42)+"4" {
|
||||||
|
t.Fatalf("operations = %#v", operations)
|
||||||
|
}
|
||||||
|
log, err := os.ReadFile(filepath.Join(fixture.agentDir, "error.log"))
|
||||||
|
if err != nil || !strings.Contains(string(log), "rejected prev_chain_hash 5 times") {
|
||||||
|
t.Fatalf("error log = %q, %v", log, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestClaudeAuditStopsRetryingWhenBudgetSpent(t *testing.T) {
|
||||||
|
fixture, operations, elapsed := runClaudeAuditAgainst(t, &mismatchAPI{rejections: 99, delay: 2600 * time.Millisecond})
|
||||||
|
if elapsed > 7500*time.Millisecond {
|
||||||
|
t.Fatalf("hook ran %s", elapsed)
|
||||||
|
}
|
||||||
|
if len(operations) < 2 || len(operations) > 3 {
|
||||||
|
t.Fatalf("submissions = %d", len(operations))
|
||||||
|
}
|
||||||
|
log, err := os.ReadFile(filepath.Join(fixture.agentDir, "error.log"))
|
||||||
|
if err != nil || !(strings.Contains(string(log), "aborted") || strings.Contains(string(log), "retry budget exhausted")) {
|
||||||
|
t.Fatalf("error log = %q, %v", log, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestClaudeAuditDoesNotRegressAConcurrentlyAdvancedChainHead(t *testing.T) {
|
||||||
|
fixture, operations, _ := runClaudeAuditAgainst(t, &mismatchAPI{staleHead: true})
|
||||||
|
if len(operations) != 1 || operations[0]["prev_chain_hash"] != strings.Repeat("A", 43) {
|
||||||
|
t.Fatalf("operations = %#v", operations)
|
||||||
|
}
|
||||||
|
state, err := os.ReadFile(filepath.Join(fixture.agentDir, "chain-state.json"))
|
||||||
|
if err != nil || !strings.Contains(string(state), strings.Repeat("B", 43)) {
|
||||||
|
t.Fatalf("chain state = %s, %v", state, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestClaudeAuditClearsAStaleChainStateLock(t *testing.T) {
|
||||||
|
fixture, operations, _ := runClaudeAuditAgainst(t, &mismatchAPI{staleLock: true})
|
||||||
|
if len(operations) != 1 {
|
||||||
|
t.Fatalf("operations = %#v", operations)
|
||||||
|
}
|
||||||
|
if _, err := os.Stat(filepath.Join(fixture.agentDir, "chain-state.json.lock")); !os.IsNotExist(err) {
|
||||||
|
t.Fatalf("stale lock still present: %v", err)
|
||||||
|
}
|
||||||
|
state, err := os.ReadFile(filepath.Join(fixture.agentDir, "chain-state.json"))
|
||||||
|
if err != nil || !strings.Contains(string(state), fmt.Sprint(operations[0]["chain_hash"])) {
|
||||||
|
t.Fatalf("chain state = %s, %v", state, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestClaudeAuditDoesNotReclaimALiveOwnersLock(t *testing.T) {
|
||||||
|
fixture, operations, _ := runClaudeAuditAgainst(t, &mismatchAPI{liveLock: true})
|
||||||
|
if len(operations) != 1 {
|
||||||
|
t.Fatalf("operations = %#v", operations)
|
||||||
|
}
|
||||||
|
if _, err := os.Stat(filepath.Join(fixture.agentDir, "chain-state.json.lock")); err != nil {
|
||||||
|
t.Fatalf("live lock was removed: %v", err)
|
||||||
|
}
|
||||||
|
log, err := os.ReadFile(filepath.Join(fixture.agentDir, "error.log"))
|
||||||
|
if err != nil || !strings.Contains(string(log), "lock timed out") {
|
||||||
|
t.Fatalf("error log = %q, %v", log, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -150,10 +150,10 @@ func TestClaudeValidatesManagedRuntimeInputsBeforeWrites(t *testing.T) {
|
|||||||
}, "query parameters"},
|
}, "query parameters"},
|
||||||
{"guard path", func(f *claudeFixture) {
|
{"guard path", func(f *claudeFixture) {
|
||||||
f.config.GuardScriptPath = filepath.Join(f.homeDir, "outside", "guard.js")
|
f.config.GuardScriptPath = filepath.Join(f.homeDir, "outside", "guard.js")
|
||||||
}, "managed Elydora agent directory"},
|
}, "managed agent directory"},
|
||||||
{"audit path", func(f *claudeFixture) {
|
{"audit path", func(f *claudeFixture) {
|
||||||
f.config.HookScript = filepath.Join(f.homeDir, "outside", "hook.js")
|
f.config.HookScript = filepath.Join(f.homeDir, "outside", "hook.js")
|
||||||
}, "managed Elydora agent directory"},
|
}, "managed agent directory"},
|
||||||
}
|
}
|
||||||
for _, testCase := range tests {
|
for _, testCase := range tests {
|
||||||
t.Run(testCase.name, func(t *testing.T) {
|
t.Run(testCase.name, func(t *testing.T) {
|
||||||
|
|||||||
@@ -1,231 +1,5 @@
|
|||||||
package plugins
|
package plugins
|
||||||
|
|
||||||
import (
|
func claudeRuntimeFilesExist(contracts []managedRuntimeContract) (bool, error) {
|
||||||
"fmt"
|
return managedRuntimeFilesExist(contracts, claudeAgentKey, "Claude Code", nil)
|
||||||
"path/filepath"
|
|
||||||
"sort"
|
|
||||||
"strings"
|
|
||||||
)
|
|
||||||
|
|
||||||
func readClaudeRuntimeConfig(path string) (map[string]any, bool, error) {
|
|
||||||
snapshot, err := readManagedFile(path, "Elydora runtime config", maxRuntimeConfigBytes)
|
|
||||||
if err != nil || snapshot == nil {
|
|
||||||
return nil, snapshot != nil, err
|
|
||||||
}
|
|
||||||
label := fmt.Sprintf("Elydora runtime config at %s", path)
|
|
||||||
config, err := decodeStrictJSONObject(snapshot.contents, label)
|
|
||||||
return config, true, err
|
|
||||||
}
|
|
||||||
|
|
||||||
func requireClaudeRuntimeString(
|
|
||||||
config map[string]any,
|
|
||||||
field string,
|
|
||||||
configPath string,
|
|
||||||
) (string, error) {
|
|
||||||
value, ok := config[field].(string)
|
|
||||||
if !ok || strings.TrimSpace(value) == "" {
|
|
||||||
return "", fmt.Errorf(
|
|
||||||
"elydora runtime config %s is invalid: %s",
|
|
||||||
field,
|
|
||||||
configPath,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
return value, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func validateClaudeRuntimeConfig(
|
|
||||||
config map[string]any,
|
|
||||||
expectedAgentID string,
|
|
||||||
configPath string,
|
|
||||||
) error {
|
|
||||||
supported := stringSet(
|
|
||||||
"org_id", "agent_id", "kid", "base_url", "token", "agent_name",
|
|
||||||
)
|
|
||||||
fields := make([]string, 0, len(config))
|
|
||||||
for field := range config {
|
|
||||||
fields = append(fields, field)
|
|
||||||
}
|
|
||||||
sort.Strings(fields)
|
|
||||||
for _, field := range fields {
|
|
||||||
if _, ok := supported[field]; !ok {
|
|
||||||
return fmt.Errorf(
|
|
||||||
`elydora runtime config has unsupported field %q: %s`,
|
|
||||||
field,
|
|
||||||
configPath,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if _, err := requireClaudeRuntimeString(config, "org_id", configPath); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if _, err := requireClaudeRuntimeString(config, "kid", configPath); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
agentID, err := requireClaudeRuntimeString(config, "agent_id", configPath)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if !sameClaudeAgentID(agentID, expectedAgentID) ||
|
|
||||||
config["agent_name"] != claudeAgentKey {
|
|
||||||
return fmt.Errorf(
|
|
||||||
"elydora runtime identity does not match Claude Code hooks: %s",
|
|
||||||
configPath,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
if _, exists := config["token"]; exists {
|
|
||||||
if _, err := requireClaudeRuntimeString(config, "token", configPath); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
baseURL, err := requireClaudeRuntimeString(config, "base_url", configPath)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
if err := validateManagedBaseURL(baseURL); err != nil {
|
|
||||||
return fmt.Errorf(
|
|
||||||
"elydora runtime config base URL is invalid at %s: %w",
|
|
||||||
configPath,
|
|
||||||
err,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func validateClaudeRuntimeIdentity(agentDirectory, agentID string) error {
|
|
||||||
runtimeRoot := filepath.Dir(agentDirectory)
|
|
||||||
rootExists, err := managedPhysicalDirectoryExists(
|
|
||||||
runtimeRoot,
|
|
||||||
"Elydora runtime directory",
|
|
||||||
)
|
|
||||||
if err != nil || !rootExists {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
directoryExists, err := managedPhysicalDirectoryExists(
|
|
||||||
agentDirectory,
|
|
||||||
"Elydora agent runtime directory",
|
|
||||||
)
|
|
||||||
if err != nil || !directoryExists {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
configPath := filepath.Join(agentDirectory, "config.json")
|
|
||||||
config, configExists, err := readClaudeRuntimeConfig(configPath)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
artifactExists := false
|
|
||||||
for _, item := range []struct {
|
|
||||||
path string
|
|
||||||
label string
|
|
||||||
limit int64
|
|
||||||
}{
|
|
||||||
{filepath.Join(agentDirectory, "private.key"), "Elydora private key", maxProtectedSecretBytes},
|
|
||||||
{filepath.Join(agentDirectory, claudeGuardScript), "Elydora guard runtime", maxManagedSourceBytes},
|
|
||||||
{filepath.Join(agentDirectory, claudeAuditScript), "Elydora audit runtime", maxManagedSourceBytes},
|
|
||||||
{filepath.Join(agentDirectory, "chain-state.json"), "Elydora chain state", maxRuntimeConfigBytes},
|
|
||||||
{filepath.Join(agentDirectory, "status-cache.json"), "Elydora status cache", maxRuntimeConfigBytes},
|
|
||||||
{filepath.Join(agentDirectory, "error.log"), "Elydora error log", maxManagedSourceBytes},
|
|
||||||
} {
|
|
||||||
exists, inspectErr := managedPhysicalFileExists(item.path, item.label, item.limit)
|
|
||||||
if inspectErr != nil {
|
|
||||||
return inspectErr
|
|
||||||
}
|
|
||||||
artifactExists = artifactExists || exists
|
|
||||||
}
|
|
||||||
if !configExists {
|
|
||||||
if artifactExists {
|
|
||||||
return fmt.Errorf(
|
|
||||||
"elydora runtime identity cannot be verified without config.json: %s",
|
|
||||||
agentDirectory,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
configuredID, ok := config["agent_id"].(string)
|
|
||||||
if !ok || config["agent_name"] != claudeAgentKey ||
|
|
||||||
!sameClaudeAgentID(configuredID, agentID) {
|
|
||||||
return fmt.Errorf(
|
|
||||||
"elydora runtime config identity does not match Claude Code agent %s: %s",
|
|
||||||
agentID,
|
|
||||||
configPath,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func claudeRuntimeContractExists(contract claudeRuntimeContract) (bool, error) {
|
|
||||||
runtimeRoot, err := AgentRuntimeRoot()
|
|
||||||
if err != nil {
|
|
||||||
return false, err
|
|
||||||
}
|
|
||||||
agentDirectory := filepath.Dir(contract.guardPath)
|
|
||||||
if !sameClaudePath(filepath.Dir(agentDirectory), runtimeRoot) ||
|
|
||||||
!sameClaudePath(
|
|
||||||
contract.auditPath,
|
|
||||||
filepath.Join(agentDirectory, claudeAuditScript),
|
|
||||||
) {
|
|
||||||
return false, nil
|
|
||||||
}
|
|
||||||
rootExists, err := managedPhysicalDirectoryExists(
|
|
||||||
runtimeRoot,
|
|
||||||
"Elydora runtime directory",
|
|
||||||
)
|
|
||||||
if err != nil || !rootExists {
|
|
||||||
return false, err
|
|
||||||
}
|
|
||||||
directoryExists, err := managedPhysicalDirectoryExists(
|
|
||||||
agentDirectory,
|
|
||||||
"Elydora agent runtime directory",
|
|
||||||
)
|
|
||||||
if err != nil || !directoryExists {
|
|
||||||
return false, err
|
|
||||||
}
|
|
||||||
configPath := filepath.Join(agentDirectory, "config.json")
|
|
||||||
keyPath := filepath.Join(agentDirectory, "private.key")
|
|
||||||
config, configExists, err := readClaudeRuntimeConfig(configPath)
|
|
||||||
if err != nil {
|
|
||||||
return false, err
|
|
||||||
}
|
|
||||||
key, err := readManagedFile(keyPath, "Elydora private key", maxProtectedSecretBytes)
|
|
||||||
if err != nil {
|
|
||||||
return false, err
|
|
||||||
}
|
|
||||||
guard, err := readManagedFile(
|
|
||||||
contract.guardPath,
|
|
||||||
"Elydora guard runtime",
|
|
||||||
maxManagedSourceBytes,
|
|
||||||
)
|
|
||||||
if err != nil {
|
|
||||||
return false, err
|
|
||||||
}
|
|
||||||
audit, err := readManagedFile(
|
|
||||||
contract.auditPath,
|
|
||||||
"Elydora audit runtime",
|
|
||||||
maxManagedSourceBytes,
|
|
||||||
)
|
|
||||||
if err != nil {
|
|
||||||
return false, err
|
|
||||||
}
|
|
||||||
if !configExists || key == nil || guard == nil || audit == nil {
|
|
||||||
return false, nil
|
|
||||||
}
|
|
||||||
if err := validateClaudeRuntimeConfig(config, contract.agentID, configPath); err != nil {
|
|
||||||
return false, err
|
|
||||||
}
|
|
||||||
if err := validateManagedPrivateKey(string(key.contents)); err != nil {
|
|
||||||
return false, fmt.Errorf("elydora private key at %s: %w", keyPath, err)
|
|
||||||
}
|
|
||||||
return len(guard.contents) > 0 && len(audit.contents) > 0, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func claudeRuntimeFilesExist(contracts []claudeRuntimeContract) (bool, error) {
|
|
||||||
for _, contract := range contracts {
|
|
||||||
exists, err := claudeRuntimeContractExists(contract)
|
|
||||||
if err != nil {
|
|
||||||
return false, err
|
|
||||||
}
|
|
||||||
if exists {
|
|
||||||
return true, nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return false, nil
|
|
||||||
}
|
}
|
||||||
@@ -59,6 +59,7 @@ func prepareClaudeFixture(t *testing.T, options claudeFixtureOptions) *claudeFix
|
|||||||
t.Helper()
|
t.Helper()
|
||||||
root := t.TempDir()
|
root := t.TempDir()
|
||||||
homeDir := filepath.Join(root, "home with 'quote %CLAUDE_HOOK_EVENT%")
|
homeDir := filepath.Join(root, "home with 'quote %CLAUDE_HOOK_EVENT%")
|
||||||
|
preparePrivateTransactionTestDirectory(t, homeDir)
|
||||||
projectDir := filepath.Join(root, "project with spaces")
|
projectDir := filepath.Join(root, "project with spaces")
|
||||||
if err := os.MkdirAll(projectDir, 0700); err != nil {
|
if err := os.MkdirAll(projectDir, 0700); err != nil {
|
||||||
t.Fatalf("create project directory: %v", err)
|
t.Fatalf("create project directory: %v", err)
|
||||||
|
|||||||
@@ -60,7 +60,7 @@ func TestClaudeInstallRollsBackAllRuntimesAfterSettingsFailure(t *testing.T) {
|
|||||||
}
|
}
|
||||||
before := snapshotClaudeFiles(t, paths...)
|
before := snapshotClaudeFiles(t, paths...)
|
||||||
fixture.plugin.rename = func(source, destination string) error {
|
fixture.plugin.rename = func(source, destination string) error {
|
||||||
if sameClaudePath(destination, fixture.configPath) &&
|
if sameManagedPath(destination, fixture.configPath) &&
|
||||||
strings.HasSuffix(source, ".tmp") {
|
strings.HasSuffix(source, ".tmp") {
|
||||||
return errors.New("injected Claude settings failure")
|
return errors.New("injected Claude settings failure")
|
||||||
}
|
}
|
||||||
@@ -114,13 +114,14 @@ func TestPreparedClaudeInstallRejectsConcurrentSettingsChange(t *testing.T) {
|
|||||||
if err := os.WriteFile(fixture.configPath, concurrent, 0600); err != nil {
|
if err := os.WriteFile(fixture.configPath, concurrent, 0600); err != nil {
|
||||||
t.Fatalf("write concurrent settings: %v", err)
|
t.Fatalf("write concurrent settings: %v", err)
|
||||||
}
|
}
|
||||||
err = writeClaudeChanges(
|
err = writeManagedChanges(
|
||||||
changes,
|
changes,
|
||||||
"Install Claude Code hooks",
|
"Install Claude Code hooks",
|
||||||
nil,
|
nil,
|
||||||
paths.runtimeRoot,
|
paths.runtimeRoot,
|
||||||
paths.agentDirectory,
|
paths.agentDirectory,
|
||||||
filepath.Dir(document.filePath),
|
filepath.Dir(document.filePath),
|
||||||
|
"Claude Code configuration directory",
|
||||||
)
|
)
|
||||||
if err == nil || !strings.Contains(err.Error(), "changed during installation") {
|
if err == nil || !strings.Contains(err.Error(), "changed during installation") {
|
||||||
t.Fatalf("prepared install error = %v", err)
|
t.Fatalf("prepared install error = %v", err)
|
||||||
@@ -176,7 +177,7 @@ func TestClaudeUninstallPreservesSettingsAfterCommitFailure(t *testing.T) {
|
|||||||
t.Fatalf("read installed settings: %v", err)
|
t.Fatalf("read installed settings: %v", err)
|
||||||
}
|
}
|
||||||
fixture.plugin.rename = func(source, destination string) error {
|
fixture.plugin.rename = func(source, destination string) error {
|
||||||
if sameClaudePath(destination, fixture.configPath) &&
|
if sameManagedPath(destination, fixture.configPath) &&
|
||||||
strings.HasSuffix(source, ".tmp") {
|
strings.HasSuffix(source, ".tmp") {
|
||||||
return errors.New("injected Claude uninstall failure")
|
return errors.New("injected Claude uninstall failure")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,8 +7,7 @@ type ClinePlugin struct {
|
|||||||
rename renameFunc
|
rename renameFunc
|
||||||
}
|
}
|
||||||
|
|
||||||
// ManagesGuardRuntime reports that Cline commits both generated runtimes and
|
// ManagesGuardRuntime reports that the guard is committed with the audit runtime.
|
||||||
// both native hook files in one transaction.
|
|
||||||
func (p *ClinePlugin) ManagesGuardRuntime() bool {
|
func (p *ClinePlugin) ManagesGuardRuntime() bool {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,7 +6,6 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"runtime"
|
|
||||||
"strings"
|
"strings"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -80,37 +79,6 @@ func resolveClineHookFiles() (clineHookPaths, error) {
|
|||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func sameClineAgentID(left, right string) bool {
|
|
||||||
if runtime.GOOS == "windows" {
|
|
||||||
return strings.EqualFold(left, right)
|
|
||||||
}
|
|
||||||
return left == right
|
|
||||||
}
|
|
||||||
|
|
||||||
func normalizeClinePath(value string) (string, error) {
|
|
||||||
absolute, err := filepath.Abs(value)
|
|
||||||
if err != nil {
|
|
||||||
return "", err
|
|
||||||
}
|
|
||||||
normalized := filepath.Clean(absolute)
|
|
||||||
if runtime.GOOS == "windows" {
|
|
||||||
normalized = strings.ToLower(normalized)
|
|
||||||
}
|
|
||||||
return normalized, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func sameClinePath(left, right string) (bool, error) {
|
|
||||||
normalizedLeft, err := normalizeClinePath(left)
|
|
||||||
if err != nil {
|
|
||||||
return false, err
|
|
||||||
}
|
|
||||||
normalizedRight, err := normalizeClinePath(right)
|
|
||||||
if err != nil {
|
|
||||||
return false, err
|
|
||||||
}
|
|
||||||
return normalizedLeft == normalizedRight, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func buildClineMetadata(kind, agentID, runtimePath string) (clineHookMetadata, error) {
|
func buildClineMetadata(kind, agentID, runtimePath string) (clineHookMetadata, error) {
|
||||||
metadata := clineHookMetadata{
|
metadata := clineHookMetadata{
|
||||||
Version: clineMetadataVersion,
|
Version: clineMetadataVersion,
|
||||||
@@ -314,7 +282,7 @@ func clineContractForFiles(
|
|||||||
if guard.Kind != "guard" || audit.Kind != "audit" {
|
if guard.Kind != "guard" || audit.Kind != "audit" {
|
||||||
return nil, fmt.Errorf("Elydora Cline hook files contain mismatched event metadata")
|
return nil, fmt.Errorf("Elydora Cline hook files contain mismatched event metadata")
|
||||||
}
|
}
|
||||||
if !sameClineAgentID(guard.AgentID, audit.AgentID) {
|
if !sameManagedAgentID(guard.AgentID, audit.AgentID) {
|
||||||
return nil, fmt.Errorf("Elydora Cline hook files reference different agents")
|
return nil, fmt.Errorf("Elydora Cline hook files reference different agents")
|
||||||
}
|
}
|
||||||
agentDirectory, err := ResolveAgentRuntimeDirectory(guard.AgentID)
|
agentDirectory, err := ResolveAgentRuntimeDirectory(guard.AgentID)
|
||||||
@@ -323,15 +291,8 @@ func clineContractForFiles(
|
|||||||
}
|
}
|
||||||
expectedGuard := filepath.Join(agentDirectory, clineGuardScript)
|
expectedGuard := filepath.Join(agentDirectory, clineGuardScript)
|
||||||
expectedAudit := filepath.Join(agentDirectory, clineAuditScript)
|
expectedAudit := filepath.Join(agentDirectory, clineAuditScript)
|
||||||
guardMatches, err := sameClinePath(guard.RuntimePath, expectedGuard)
|
if !sameManagedPath(guard.RuntimePath, expectedGuard) ||
|
||||||
if err != nil {
|
!sameManagedPath(audit.RuntimePath, expectedAudit) {
|
||||||
return nil, fmt.Errorf("compare Cline guard runtime path: %w", err)
|
|
||||||
}
|
|
||||||
auditMatches, err := sameClinePath(audit.RuntimePath, expectedAudit)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("compare Cline audit runtime path: %w", err)
|
|
||||||
}
|
|
||||||
if !guardMatches || !auditMatches {
|
|
||||||
return nil, fmt.Errorf("Elydora Cline hook metadata references an unexpected runtime path")
|
return nil, fmt.Errorf("Elydora Cline hook metadata references an unexpected runtime path")
|
||||||
}
|
}
|
||||||
return &clineRuntimeContract{
|
return &clineRuntimeContract{
|
||||||
|
|||||||
@@ -1,97 +1,21 @@
|
|||||||
package plugins
|
package plugins
|
||||||
|
|
||||||
import (
|
import "fmt"
|
||||||
"encoding/json"
|
|
||||||
"fmt"
|
|
||||||
"os"
|
|
||||||
"path/filepath"
|
|
||||||
"strings"
|
|
||||||
)
|
|
||||||
|
|
||||||
type clineRuntimePaths struct {
|
type clineRuntimePaths struct {
|
||||||
runtimeRoot string
|
managedRuntimePaths
|
||||||
agentDirectory string
|
|
||||||
configPath string
|
|
||||||
keyPath string
|
|
||||||
guardPath string
|
|
||||||
auditPath string
|
|
||||||
hooks clineHookPaths
|
hooks clineHookPaths
|
||||||
}
|
}
|
||||||
|
|
||||||
func validateClineInstallConfig(config InstallConfig) error {
|
func clineAgentPaths(config InstallConfig, hooks clineHookPaths) (*clineRuntimePaths, error) {
|
||||||
for _, field := range []struct{ name, value string }{
|
if err := validateManagedInstallConfig(config, clineAgentKey, "Cline"); err != nil {
|
||||||
{"agent name", config.AgentName},
|
|
||||||
{"organization ID", config.OrgID},
|
|
||||||
{"agent ID", config.AgentID},
|
|
||||||
{"key ID", config.KID},
|
|
||||||
{"private key", config.PrivateKey},
|
|
||||||
{"base URL", config.BaseURL},
|
|
||||||
{"guard script path", config.GuardScriptPath},
|
|
||||||
} {
|
|
||||||
if field.value == "" {
|
|
||||||
return fmt.Errorf("%s is required", field.name)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if config.AgentName != clineAgentKey {
|
|
||||||
return fmt.Errorf("Cline installation requires agent name %s", clineAgentKey)
|
|
||||||
}
|
|
||||||
if strings.TrimSpace(config.OrgID) == "" {
|
|
||||||
return fmt.Errorf("organization ID is required")
|
|
||||||
}
|
|
||||||
if strings.TrimSpace(config.KID) == "" {
|
|
||||||
return fmt.Errorf("key ID is required")
|
|
||||||
}
|
|
||||||
if config.Token != "" && strings.TrimSpace(config.Token) == "" {
|
|
||||||
return fmt.Errorf("token must contain a non-whitespace value when provided")
|
|
||||||
}
|
|
||||||
if err := validateManagedPrivateKey(config.PrivateKey); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
return validateManagedBaseURL(config.BaseURL)
|
|
||||||
}
|
|
||||||
|
|
||||||
func clineAgentPaths(
|
|
||||||
config InstallConfig,
|
|
||||||
hooks clineHookPaths,
|
|
||||||
) (*clineRuntimePaths, error) {
|
|
||||||
if err := validateClineInstallConfig(config); err != nil {
|
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
runtimeRoot, err := AgentRuntimeRoot()
|
paths, err := resolveManagedRuntimePaths(config, clineGuardScript, clineAuditScript)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
agentDirectory, err := ResolveAgentRuntimeDirectory(config.AgentID)
|
return &clineRuntimePaths{managedRuntimePaths: *paths, hooks: hooks}, nil
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
paths := &clineRuntimePaths{
|
|
||||||
runtimeRoot: runtimeRoot, agentDirectory: agentDirectory, hooks: hooks,
|
|
||||||
configPath: filepath.Join(agentDirectory, "config.json"),
|
|
||||||
keyPath: filepath.Join(agentDirectory, "private.key"),
|
|
||||||
guardPath: filepath.Join(agentDirectory, clineGuardScript),
|
|
||||||
auditPath: filepath.Join(agentDirectory, clineAuditScript),
|
|
||||||
}
|
|
||||||
if !filepath.IsAbs(config.GuardScriptPath) ||
|
|
||||||
!sameClinePathValue(config.GuardScriptPath, paths.guardPath) {
|
|
||||||
return nil, fmt.Errorf(
|
|
||||||
"Elydora guard runtime must use the managed agent directory: %s",
|
|
||||||
paths.guardPath,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
if config.HookScript != "" && (!filepath.IsAbs(config.HookScript) ||
|
|
||||||
!sameClinePathValue(config.HookScript, paths.auditPath)) {
|
|
||||||
return nil, fmt.Errorf(
|
|
||||||
"Elydora audit runtime must use the managed agent directory: %s",
|
|
||||||
paths.auditPath,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
return paths, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func sameClinePathValue(left, right string) bool {
|
|
||||||
matches, err := sameClinePath(left, right)
|
|
||||||
return err == nil && matches
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func preflightClineInstallation(
|
func preflightClineInstallation(
|
||||||
@@ -102,39 +26,20 @@ func preflightClineInstallation(
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
if err := validateClineRuntimeIdentity(paths.agentDirectory, config.AgentID); err != nil {
|
if err := validateManagedRuntimeIdentity(
|
||||||
|
paths.agentDirectory, config.AgentID, clineAgentKey, "Cline",
|
||||||
|
); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
nodePath, err := resolveNodeRuntime()
|
if _, err := resolveAbsoluteNodeRuntime("Cline"); err != nil {
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
if !filepath.IsAbs(nodePath) || !isClaudeNodeExecutable(nodePath) {
|
|
||||||
return nil, fmt.Errorf("Cline hooks require an absolute Node.js executable path")
|
|
||||||
}
|
|
||||||
return paths, nil
|
return paths, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func buildClineRuntimeConfig(config InstallConfig) ([]byte, error) {
|
func clineExpectedScripts(agentID string, _ map[string]any) ([]byte, []byte) {
|
||||||
value := map[string]any{
|
return []byte(generateGuardScript(clineAgentKey, agentID, "", false, "")),
|
||||||
"org_id": config.OrgID, "agent_id": config.AgentID, "kid": config.KID,
|
[]byte(buildHookScriptWithOutput(clineAgentKey, agentID, "", false, true))
|
||||||
"base_url": config.BaseURL, "agent_name": clineAgentKey,
|
|
||||||
}
|
|
||||||
if config.Token != "" {
|
|
||||||
value["token"] = config.Token
|
|
||||||
}
|
|
||||||
encoded, err := json.MarshalIndent(value, "", " ")
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("encode Elydora runtime config: %w", err)
|
|
||||||
}
|
|
||||||
encoded = append(encoded, '\n')
|
|
||||||
if len(encoded) > maxRuntimeConfigBytes {
|
|
||||||
return nil, fmt.Errorf(
|
|
||||||
"Elydora runtime config exceeds %d bytes after JSON encoding",
|
|
||||||
maxRuntimeConfigBytes,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
return encoded, nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func prepareClineInstallationChanges(
|
func prepareClineInstallationChanges(
|
||||||
@@ -146,11 +51,9 @@ func prepareClineInstallationChanges(
|
|||||||
if paths == nil {
|
if paths == nil {
|
||||||
return nil, fmt.Errorf("prepared Cline installation is required")
|
return nil, fmt.Errorf("prepared Cline installation is required")
|
||||||
}
|
}
|
||||||
if err := validateClineRuntimeIdentity(paths.agentDirectory, config.AgentID); err != nil {
|
if err := validateManagedRuntimeIdentity(
|
||||||
return nil, err
|
paths.agentDirectory, config.AgentID, clineAgentKey, "Cline",
|
||||||
}
|
); err != nil {
|
||||||
runtimeConfig, err := buildClineRuntimeConfig(config)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
guardMetadata, err := buildClineMetadata("guard", config.AgentID, paths.guardPath)
|
guardMetadata, err := buildClineMetadata("guard", config.AgentID, paths.guardPath)
|
||||||
@@ -175,24 +78,13 @@ func prepareClineInstallationChanges(
|
|||||||
); err != nil {
|
); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
runtimeItems := []struct {
|
guardScript, auditScript := clineExpectedScripts(config.AgentID, nil)
|
||||||
path, label string
|
changes, err := managedRuntimeFileChanges(
|
||||||
content []byte
|
config, &paths.managedRuntimePaths, clineAgentKey, string(guardScript), string(auditScript),
|
||||||
mode os.FileMode
|
)
|
||||||
}{
|
|
||||||
{paths.guardPath, "Elydora guard runtime", []byte(generateGuardScript(clineAgentKey, config.AgentID, "", false, "")), 0700},
|
|
||||||
{paths.configPath, "Elydora runtime config", runtimeConfig, 0600},
|
|
||||||
{paths.keyPath, "Elydora private key", []byte(config.PrivateKey), 0600},
|
|
||||||
{paths.auditPath, "Elydora audit runtime", []byte(buildHookScriptWithOutput(clineAgentKey, config.AgentID, "", false, true)), 0700},
|
|
||||||
}
|
|
||||||
changes := make([]*fileChange, 0, len(runtimeItems)+2)
|
|
||||||
for _, item := range runtimeItems {
|
|
||||||
change, err := prepareFileChange(item.path, item.label, item.content, item.mode)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
changes = append(changes, change)
|
|
||||||
}
|
|
||||||
for _, item := range []struct {
|
for _, item := range []struct {
|
||||||
file clineHookFile
|
file clineHookFile
|
||||||
label string
|
label string
|
||||||
|
|||||||
Loaded 100 of 498 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user