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 functions focused on one ownership 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.
|
||||
- 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.
|
||||
@@ -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.
|
||||
- Avoid compatibility shims without a named public contract.
|
||||
- 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 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.
|
||||
- Keep private keys and API tokens out of process arguments. CLIs accept them through hidden terminal input or owner-only credential files.
|
||||
- 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.
|
||||
- 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.
|
||||
|
||||
@@ -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/).
|
||||
|
||||
## [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
|
||||
|
||||
### Added
|
||||
|
||||
+20
-6
@@ -1,6 +1,6 @@
|
||||
# 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 Compose | v2.20+ |
|
||||
| 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) |
|
||||
|
||||
### Running the API Server
|
||||
@@ -50,7 +50,7 @@ This project follows the [Contributor Covenant](https://www.contributor-covenant
|
||||
|
||||
```bash
|
||||
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**
|
||||
@@ -154,11 +154,11 @@ go build -o elydora ./cmd/elydora
|
||||
- Use strict TypeScript (`"strict": true` in tsconfig).
|
||||
- Prefer `readonly` properties on interfaces.
|
||||
- Use explicit return types on exported functions and methods.
|
||||
- No `any` types — use `unknown` and narrow explicitly.
|
||||
- No `any` types; use `unknown` and narrow explicitly.
|
||||
|
||||
### Python SDK
|
||||
|
||||
- Target Python 3.9+ compatibility.
|
||||
- Target Python 3.10+ compatibility.
|
||||
- Use type hints everywhere (checked with `mypy`).
|
||||
- Follow PEP 8 with a line length of 100.
|
||||
- Prefer keyword-only arguments for optional parameters.
|
||||
@@ -166,7 +166,7 @@ go build -o elydora ./cmd/elydora
|
||||
### Go SDK
|
||||
|
||||
- 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.
|
||||
- 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.
|
||||
- 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.
|
||||
|
||||
## 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)
|
||||
[](LICENSE)
|
||||
[](sdks/node/)
|
||||
[](sdks/python/)
|
||||
[](sdks/go/)
|
||||
[](sdks/node/)
|
||||
[](sdks/python/)
|
||||
[](sdks/go/)
|
||||
[](https://github.com/Elydora-Infrastructure/Elydora-Open-Source/actions)
|
||||
[](https://elydora.com)
|
||||
|
||||
@@ -24,17 +24,17 @@ Tamper-evident audit trails with cryptographic proof for every AI agent action.
|
||||
|
||||
## 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:**
|
||||
|
||||
- **Ed25519 signed operation records (EOR)** — every agent action is signed with the agent's private key before leaving the agent's process
|
||||
- **Chain-hashed audit trails** — each record cryptographically commits to the previous record, making silent insertion, deletion, or reordering detectable
|
||||
- **Merkle tree epoch rollups with TSA anchoring** — periodic epochs hash all records into a Merkle tree whose root is anchored via RFC 3161 Trusted Timestamping
|
||||
- **Multi-tenant with RBAC** — fine-grained role-based access control with five distinct roles from read-only investigator to organization owner
|
||||
- **Compliance exports (JSON / PDF)** — one-click export of the complete audit trail for any time range, agent, or operation type
|
||||
- **Ed25519 signed operation records (EOR)**: every agent action is signed with the agent's private key before leaving the agent's process
|
||||
- **Chain-hashed audit trails**: each record cryptographically commits to the previous record, making silent insertion, deletion, or reordering detectable
|
||||
- **Merkle tree epoch rollups with TSA anchoring**: periodic epochs hash all records into a Merkle tree whose root is anchored via RFC 3161 Trusted Timestamping
|
||||
- **Multi-tenant with RBAC**: fine-grained role-based access control with five distinct roles from read-only investigator to organization owner
|
||||
- **Compliance exports (JSON / PDF)**: one-click export of the complete audit trail for any time range, agent, or operation type
|
||||
|
||||
---
|
||||
|
||||
@@ -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
|
||||
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
|
||||
```
|
||||
|
||||
@@ -127,12 +127,15 @@ Elydora-Open-Source/
|
||||
│ ├── server/ # Elydora API server
|
||||
│ └── console/ # Next.js web management console
|
||||
├── sdks/
|
||||
│ ├── node/ # @elydora/sdk — Node.js / TypeScript SDK
|
||||
│ ├── python/ # elydora — Python SDK (sync + async)
|
||||
│ └── go/ # github.com/Elydora-Infrastructure/Elydora-Go-SDK
|
||||
│ ├── node/ # @elydora/sdk, Node.js and TypeScript
|
||||
│ ├── python/ # elydora, sync and async clients
|
||||
│ └── go/ # github.com/Elydora-Infrastructure/Elydora-Go-SDK/v2
|
||||
├── helm/
|
||||
│ └── 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
|
||||
├── LICENSE
|
||||
└── 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/console` | A web-based management console for viewing agents, browsing the audit trail, managing exports, and administering users. |
|
||||
| `sdks/node` | TypeScript SDK with full type coverage. Ships a CLI (`elydora`) and a programmatic client. Supports plugin hooks for popular AI coding agents. |
|
||||
| `sdks/python` | Python SDK with both synchronous (`ElydoraClient`) and asynchronous (`AsyncElydoraClient`) clients. Requires Python 3.9+. |
|
||||
| `sdks/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. |
|
||||
| `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.
|
||||
|
||||
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:
|
||||
|
||||
```json
|
||||
@@ -259,13 +264,13 @@ Return the current session and user profile. Requires a valid session token or c
|
||||
|
||||
#### `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 |
|
||||
|-------|------|----------|-------------|
|
||||
| `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",
|
||||
"keys": [
|
||||
{
|
||||
"kid": "my-agent-v1-key-v1",
|
||||
"kid": "my-agent-v1-key-1",
|
||||
"public_key": "<base64url Ed25519 public key>",
|
||||
"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**.
|
||||
|
||||
**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`
|
||||
|
||||
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)).
|
||||
|
||||
**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',
|
||||
integration_type: 'sdk',
|
||||
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
|
||||
@@ -602,7 +607,7 @@ npx elydora submit-op ... # Submit an operation from the CLI
|
||||
pip install elydora
|
||||
```
|
||||
|
||||
**Requirements:** Python 3.9+, `requests`, `aiohttp`, `cryptography`
|
||||
**Requirements:** Python 3.10+, `requests`, `aiohttp`, `cryptography`
|
||||
|
||||
**Quickstart (synchronous):**
|
||||
|
||||
@@ -633,7 +638,7 @@ client.register_agent({
|
||||
"agent_id": "order-processor-v1",
|
||||
"integration_type": "sdk",
|
||||
"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"]),
|
||||
"algorithm": "ed25519"}],
|
||||
})
|
||||
@@ -675,7 +680,7 @@ elydora keygen
|
||||
**Installation:**
|
||||
|
||||
```bash
|
||||
go get github.com/Elydora-Infrastructure/Elydora-Go-SDK
|
||||
go get github.com/Elydora-Infrastructure/Elydora-Go-SDK/v2
|
||||
```
|
||||
|
||||
**Requirements:** Go 1.21+
|
||||
@@ -689,7 +694,7 @@ import (
|
||||
"fmt"
|
||||
"os"
|
||||
|
||||
elydora "github.com/Elydora-Infrastructure/Elydora-Go-SDK"
|
||||
elydora "github.com/Elydora-Infrastructure/Elydora-Go-SDK/v2"
|
||||
)
|
||||
|
||||
func main() {
|
||||
@@ -746,7 +751,7 @@ go run ./cmd/elydora keygen
|
||||
|
||||
## 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.
|
||||
|
||||
@@ -755,7 +760,7 @@ The EOR is the fundamental unit of the protocol. It is constructed and signed by
|
||||
| Field | Type | Description |
|
||||
|-------|------|-------------|
|
||||
| `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 |
|
||||
| `agent_id` | string | Agent identifier |
|
||||
| `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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 |
|
||||
| `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`) |
|
||||
| `MAX_PAYLOAD_BYTES` | `65536` | Maximum size of an EOR payload |
|
||||
| `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
|
||||
|
||||
**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
|
||||
|
||||
|
||||
+7
-165
@@ -1,4 +1,3 @@
|
||||
version: '3.9'
|
||||
|
||||
services:
|
||||
# ============================================================
|
||||
@@ -55,7 +54,7 @@ services:
|
||||
retries: 10
|
||||
|
||||
# ============================================================
|
||||
# MinIO init — creates the evidence bucket
|
||||
# MinIO init: creates the evidence bucket
|
||||
# ============================================================
|
||||
minio-init:
|
||||
image: minio/mc:latest
|
||||
@@ -75,175 +74,18 @@ services:
|
||||
restart: on-failure
|
||||
|
||||
# ============================================================
|
||||
# DB migrate — runs PostgreSQL migrations
|
||||
# DB migrate: applies packages/server/migrations
|
||||
# ============================================================
|
||||
db-migrate:
|
||||
image: postgres:16-alpine
|
||||
build:
|
||||
context: ./packages/server
|
||||
dockerfile: Dockerfile
|
||||
command: ["node", "dist/migrate.js"]
|
||||
env_file: .env
|
||||
depends_on:
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
environment:
|
||||
PGPASSWORD: ${POSTGRES_PASSWORD:-elydora-secret-change-me}
|
||||
command: >
|
||||
psql -h postgres -U ${POSTGRES_USER:-elydora} -d ${POSTGRES_DB:-elydora} -c "
|
||||
CREATE TABLE IF NOT EXISTS schema_versions (
|
||||
version BIGINT PRIMARY KEY,
|
||||
applied_at BIGINT NOT NULL,
|
||||
description TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS organizations (
|
||||
org_id TEXT NOT NULL PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
created_at BIGINT NOT NULL,
|
||||
updated_at BIGINT NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS users (
|
||||
user_id TEXT NOT NULL PRIMARY KEY,
|
||||
org_id TEXT NOT NULL REFERENCES organizations (org_id),
|
||||
email TEXT NOT NULL UNIQUE,
|
||||
password_hash TEXT NOT NULL,
|
||||
display_name TEXT NOT NULL,
|
||||
role TEXT NOT NULL DEFAULT 'org_owner'
|
||||
CHECK (role IN ('org_owner', 'security_admin', 'compliance_auditor', 'readonly_investigator', 'integration_engineer')),
|
||||
status TEXT NOT NULL DEFAULT 'active'
|
||||
CHECK (status IN ('active', 'suspended')),
|
||||
created_at BIGINT NOT NULL,
|
||||
updated_at BIGINT NOT NULL
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_users_org_id ON users (org_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_users_email ON users (email);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS agents (
|
||||
agent_id TEXT NOT NULL PRIMARY KEY,
|
||||
org_id TEXT NOT NULL REFERENCES organizations (org_id),
|
||||
display_name TEXT NOT NULL,
|
||||
responsible_entity TEXT NOT NULL,
|
||||
integration_type TEXT NOT NULL
|
||||
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:
|
||||
build:
|
||||
context: ./packages/server
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
apiVersion: v2
|
||||
name: elydora
|
||||
description: Elydora — Open-source AI accountability and compliance platform
|
||||
description: Open-source AI accountability and compliance platform for Elydora
|
||||
type: application
|
||||
version: 0.1.0
|
||||
appVersion: "0.1.0"
|
||||
+1
-1
@@ -2,7 +2,7 @@
|
||||
"name": "elydora-open-source",
|
||||
"version": "0.1.0",
|
||||
"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": [
|
||||
"ai",
|
||||
"agents",
|
||||
|
||||
+18
-10
@@ -8,7 +8,8 @@ Web dashboard for the Elydora Responsibility Protocol. Provides agent management
|
||||
- **Language:** TypeScript 5.7
|
||||
- **UI:** React 19, Tailwind CSS 4
|
||||
- **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
|
||||
|
||||
@@ -16,7 +17,7 @@ Web dashboard for the Elydora Responsibility Protocol. Provides agent management
|
||||
src/
|
||||
├── app/
|
||||
│ ├── 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
|
||||
│ ├── agents/
|
||||
│ │ ├── page.tsx # Agent list, search, registration
|
||||
@@ -33,6 +34,8 @@ src/
|
||||
│ │ └── page.tsx # Audit log query builder
|
||||
│ ├── login/page.tsx # Login form
|
||||
│ ├── register/page.tsx # Registration form
|
||||
│ ├── settings/ # Organization settings
|
||||
│ ├── global-error.tsx # Root error boundary
|
||||
│ └── jwks/page.tsx # Public key display
|
||||
├── components/
|
||||
│ ├── AppShell.tsx # Main layout wrapper with sidebar
|
||||
@@ -43,6 +46,8 @@ src/
|
||||
│ ├── EpochDetailClient.tsx # Epoch proof and verification
|
||||
│ ├── ChainVisualization.tsx # Merkle chain visualization
|
||||
│ ├── VerificationChecklist.tsx # Proof verification UI
|
||||
│ ├── I18nProvider.tsx # Locale provider
|
||||
│ ├── agent-registration/ # Registration flow components
|
||||
│ └── ui/
|
||||
│ ├── PageHeader.tsx # Page title and breadcrumbs
|
||||
│ ├── DataTable.tsx # Sortable, paginated table
|
||||
@@ -50,12 +55,17 @@ src/
|
||||
│ ├── SearchInput.tsx # Search/filter input
|
||||
│ ├── StatusBadge.tsx # Status indicator
|
||||
│ ├── CopyButton.tsx # Copy-to-clipboard
|
||||
│ ├── LanguageDropdown.tsx # Locale switcher
|
||||
│ └── Sidebar.tsx # Navigation sidebar
|
||||
├── features/
|
||||
│ └── agent-registration/ # Install instructions and credential handling
|
||||
├── i18n/ # en and zh message catalogs
|
||||
├── lib/
|
||||
│ ├── api.ts # Typed REST client for all endpoints
|
||||
│ ├── auth.tsx # Auth context and hooks
|
||||
│ └── hooks.ts # SWR data-fetching hooks
|
||||
└── shared/ # Shared types (@elydora/shared)
|
||||
e2e/ # Playwright specs
|
||||
```
|
||||
|
||||
## Getting Started
|
||||
@@ -90,20 +100,17 @@ Runs at `http://localhost:3000` with Turbopack.
|
||||
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
|
||||
|
||||
```bash
|
||||
npm run deploy
|
||||
```
|
||||
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.
|
||||
|
||||
Deploys the `out/` directory to Cloudflare Pages.
|
||||
|
||||
### Type Checking
|
||||
### Checks
|
||||
|
||||
```bash
|
||||
npm run typecheck
|
||||
npm run typecheck # tsc --noEmit
|
||||
npm run test:e2e # Playwright specs in e2e/
|
||||
```
|
||||
|
||||
## Pages
|
||||
@@ -122,3 +129,4 @@ npm run typecheck
|
||||
| `/login` | Login |
|
||||
| `/register` | Register account and organization |
|
||||
| `/jwks` | Display server public keys in JWK format |
|
||||
| `/settings` | Organization settings |
|
||||
@@ -4,6 +4,8 @@ import {
|
||||
INTEGRATION_CATALOG,
|
||||
INTEGRATION_TYPES,
|
||||
} from '@/features/agent-registration/integrations';
|
||||
import enRegistration from '@/i18n/en.json';
|
||||
import zhRegistration from '@/i18n/zh.json';
|
||||
import {
|
||||
assertRegistrationResponse,
|
||||
RegistrationContractError,
|
||||
@@ -43,35 +45,161 @@ test('keeps the complete integration catalog in canonical order', () => {
|
||||
expect(INTEGRATION_TYPES).toEqual(EXPECTED_INTEGRATIONS);
|
||||
expect(INTEGRATION_CATALOG.map(({ id }) => id)).toEqual(EXPECTED_INTEGRATIONS);
|
||||
expect(new Set(INTEGRATION_CATALOG.map(({ id }) => id)).size).toBe(19);
|
||||
expect(INTEGRATION_CATALOG.filter(({ mode }) => mode === 'adapter')).toHaveLength(15);
|
||||
expect(INTEGRATION_CATALOG.filter(({ mode }) => mode === 'hooks')).toHaveLength(15);
|
||||
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', () => {
|
||||
for (const integration of INTEGRATION_CATALOG.filter(({ mode }) => mode === 'adapter')) {
|
||||
test('keeps every native hook activation plan aligned with its provider contract', () => {
|
||||
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) {
|
||||
const instructions = buildInstallInstructions(language, {
|
||||
integration,
|
||||
identity: credentials,
|
||||
baseUrl: 'https://api.example.com',
|
||||
privateKey: 'private-contract-key',
|
||||
token: 'token-contract',
|
||||
});
|
||||
|
||||
expect(instructions.setup).toContain(`--agent ${integration.id}`);
|
||||
expect(instructions.setup).toContain("'https://api.example.com'");
|
||||
expect(instructions.setup).not.toContain('private-key');
|
||||
expect(instructions.setup).not.toContain('private_key');
|
||||
expect(instructions.setup).not.toContain('--token');
|
||||
expect(instructions.setup).not.toContain('token-contract');
|
||||
expect(instructions.secretDelivery).toBe('hidden-prompts');
|
||||
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).toContain("printf '\\''%s\\n'\\'' '\\''private-contract-key'\\'' > \"$k\"");
|
||||
expect(instructions.setup).toContain("printf '\\''%s\\n'\\'' '\\''token-contract'\\'' > \"$t\"");
|
||||
expect(instructions.setup).not.toContain('chmod');
|
||||
expect(instructions.setup.endsWith("' | sh")).toBe(true);
|
||||
expect(instructions.secretDelivery).toBe('embedded');
|
||||
expect(instructions.postInstall).toEqual(integration.postInstall);
|
||||
|
||||
if (language === 'go') {
|
||||
expect(instructions.setup).toContain("--org-id 'org-contract'");
|
||||
expect(instructions.setup).toContain("--agent-id 'agent-contract'");
|
||||
expect(instructions.setup).toContain("--base-url 'https://api.example.com'");
|
||||
expect(instructions.setup).toContain("--org-id '\\''org-contract'\\''");
|
||||
expect(instructions.setup).toContain("--agent-id '\\''agent-contract'\\''");
|
||||
expect(instructions.setup).toContain("--base-url '\\''https://api.example.com'\\''");
|
||||
expect(instructions.setup).toContain('--private-key-file "$k" --token-file "$t"');
|
||||
} else {
|
||||
expect(instructions.setup).toContain("--org_id 'org-contract'");
|
||||
expect(instructions.setup).toContain("--agent_id 'agent-contract'");
|
||||
expect(instructions.setup).toContain("--base_url 'https://api.example.com'");
|
||||
expect(instructions.setup).toContain("--org_id '\\''org-contract'\\''");
|
||||
expect(instructions.setup).toContain("--agent_id '\\''agent-contract'\\''");
|
||||
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!,
|
||||
identity: credentials,
|
||||
baseUrl: 'https://api.example.com',
|
||||
privateKey: 'private-contract-key',
|
||||
token: 'token-contract',
|
||||
});
|
||||
expect(instructions.setup).toContain('agent-contract');
|
||||
expect(instructions.setup).toContain('ELYDORA_PRIVATE_KEY');
|
||||
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.usage).toContain('ai.tool_use');
|
||||
expect(instructions.verify).toBeUndefined();
|
||||
@@ -110,6 +240,8 @@ test('quotes shell metacharacters and rejects unsupported command values', () =>
|
||||
orgId: '$(Write-Output injected)',
|
||||
},
|
||||
baseUrl: 'https://api.example.com/`whoami`',
|
||||
privateKey: 'private-contract-key',
|
||||
token: 'token-contract',
|
||||
});
|
||||
expect(instructions.setup).toContain("'$(Write-Output injected)'");
|
||||
expect(instructions.setup).toContain("'https://api.example.com/`whoami`'");
|
||||
@@ -118,7 +250,23 @@ test('quotes shell metacharacters and rejects unsupported command values', () =>
|
||||
integration: integration!,
|
||||
identity: { ...credentials, orgId: "org-'unsafe" },
|
||||
baseUrl: 'https://api.example.com',
|
||||
privateKey: 'private-contract-key',
|
||||
token: 'token-contract',
|
||||
})).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', () => {
|
||||
|
||||
@@ -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 unexpected: string[] = [];
|
||||
const runtimeErrors: string[] = [];
|
||||
@@ -123,10 +123,26 @@ test('registers Grok atomically and renders verified adapter commands', async ({
|
||||
await page.goto('/agents');
|
||||
await page.getByRole('button', { name: 'Register Agent' }).click();
|
||||
await waitForRenderedDialog(page);
|
||||
await expect(page.getByRole('button', { name: 'Augment Code' })).toBeVisible();
|
||||
await expect(page.getByRole('button', { name: 'OpenAI Codex' })).toBeVisible();
|
||||
await expect(page.getByRole('button', { name: 'Kimi CLI' })).toBeVisible();
|
||||
await expect(page.getByRole('button', { name: 'Qwen Code' })).toBeVisible();
|
||||
const dialog = page.getByRole('dialog');
|
||||
await dialog.focus();
|
||||
await page.keyboard.press('Tab');
|
||||
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.getByRole('button', { name: 'Grok Build' }).click();
|
||||
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');
|
||||
await expect(setup).toContainText('npx @elydora/sdk install --agent grok');
|
||||
await expect(setup).toContainText(`--org_id '${ORG_ID}'`);
|
||||
await expect(setup).not.toContainText(TOKEN);
|
||||
await expect(setup).not.toContainText(privateKey);
|
||||
await expect(setup).not.toContainText('--token');
|
||||
await expect(setup).not.toContainText('--private_key');
|
||||
await expect(page.getByText('The CLI requests both values with terminal echo disabled.')).toBeVisible();
|
||||
await expect(setup).toContainText(`--org_id '\\''${ORG_ID}'\\''`);
|
||||
await expect(setup).toContainText(`'${TOKEN}'`);
|
||||
await expect(setup).toContainText(`'${privateKey}'`);
|
||||
await expect(setup).toContainText('mktemp "$HOME/.elydora-');
|
||||
await expect(setup).toContainText('--private_key_file "$k" --token_file "$t"');
|
||||
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.getByRole('button', { name: 'Copy setup command' }).click();
|
||||
await expect(page.getByRole('button', { name: 'Copy setup command' })).toHaveText('Copied');
|
||||
const copiedSetup = await page.evaluate(() => navigator.clipboard.readText());
|
||||
expect(copiedSetup).not.toContain(TOKEN);
|
||||
expect(copiedSetup).not.toContain(privateKey);
|
||||
expect(copiedSetup).toContain(TOKEN);
|
||||
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 expect(setup).toContainText('elydora install --agent grok');
|
||||
await expect(setup).toContainText(`--org-id '${ORG_ID}'`);
|
||||
await expect(setup).toContainText("--agent-id 'agent-");
|
||||
await expect(setup).not.toContainText('--private-key');
|
||||
await expect(setup).not.toContainText('--token');
|
||||
await expect(setup).toContainText(`--org-id '\\''${ORG_ID}'\\''`);
|
||||
await expect(setup).toContainText("--agent-id '\\''agent-");
|
||||
await expect(setup).toContainText('--private-key-file "$k" --token-file "$t"');
|
||||
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.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');
|
||||
expect(registrationRequests).toHaveLength(1);
|
||||
expect(registrationRequests[0]?.method).toBe('POST');
|
||||
@@ -249,6 +294,40 @@ test('renders custom SDK setup with runtime secret references', async ({ page })
|
||||
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 }) => {
|
||||
const observed: ObservedRequest[] = [];
|
||||
const unexpected: string[] = [];
|
||||
@@ -263,11 +342,21 @@ test('loads the Chinese registration resources', async ({ page }) => {
|
||||
await page.goto('/agents');
|
||||
await expect(page.locator('html')).toHaveAttribute('lang', 'zh');
|
||||
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.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(runtimeErrors).toEqual([]);
|
||||
});
|
||||
|
||||
@@ -353,3 +353,21 @@ body {
|
||||
.check-fail {
|
||||
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 type { AgentKey, Operation } from '@elydora/shared';
|
||||
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 }) {
|
||||
const { t } = useTranslation();
|
||||
@@ -37,7 +37,7 @@ export default function AgentDetailClient({ agentId }: { agentId: string }) {
|
||||
|
||||
const agent = agentData?.agent;
|
||||
const supportsFreeze = agent
|
||||
? ADAPTER_INTEGRATION_IDS.has(agent.integration_type)
|
||||
? HOOK_INTEGRATION_IDS.has(agent.integration_type)
|
||||
: false;
|
||||
const keys = agentData?.keys ?? [];
|
||||
const operations = opsData?.operations ?? [];
|
||||
|
||||
@@ -93,7 +93,7 @@ export default function OperationDetailCard({ operation, receipt, payload }: Ope
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Payload — what the operation actually did */}
|
||||
{/* Payload: what the operation did */}
|
||||
{payload && (
|
||||
<div className="border border-border bg-surface p-4 sm:p-6 relative">
|
||||
<span className="crosshair ch-tl" />
|
||||
|
||||
@@ -7,7 +7,9 @@ import type { IntegrationCatalogItem } from '@/features/agent-registration/integ
|
||||
import {
|
||||
buildInstallInstructions,
|
||||
SDK_LANGUAGES,
|
||||
SHELLS,
|
||||
type SdkLanguage,
|
||||
type Shell,
|
||||
} from '@/features/agent-registration/install-instructions';
|
||||
import CopyControl from './CopyControl';
|
||||
|
||||
@@ -28,6 +30,7 @@ export default function ConnectStep({
|
||||
}: ConnectStepProps) {
|
||||
const { t } = useTranslation();
|
||||
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 instructions = useMemo(
|
||||
() => buildInstallInstructions(language, {
|
||||
@@ -38,9 +41,12 @@ export default function ConnectStep({
|
||||
orgId: credentials.orgId,
|
||||
},
|
||||
baseUrl,
|
||||
}),
|
||||
[baseUrl, credentials.agentId, credentials.kid, credentials.orgId, integration, language],
|
||||
privateKey: credentials.privateKey,
|
||||
token,
|
||||
}, shell),
|
||||
[baseUrl, credentials, integration, language, shell, token],
|
||||
);
|
||||
const PostInstallList = instructions.postInstall?.kind === 'alternatives' ? 'ul' : 'ol';
|
||||
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
@@ -66,10 +72,29 @@ export default function ConnectStep({
|
||||
))}
|
||||
</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">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<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>
|
||||
<CopyControl
|
||||
value={instructions.setup}
|
||||
@@ -138,14 +163,35 @@ export default function ConnectStep({
|
||||
)}
|
||||
|
||||
{instructions.postInstall && (
|
||||
<ol className="border-t border-border" aria-label={t('agentRegistration.postInstall')}>
|
||||
{instructions.postInstall.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">
|
||||
<span className="text-ink-dim tabular-nums">{index + 1}</span>
|
||||
<span>{step}</span>
|
||||
<PostInstallList className="border-t border-border" aria-label={t('agentRegistration.postInstall')}>
|
||||
{instructions.postInstall.steps.map((step, index) => (
|
||||
<li
|
||||
key={`${step.key}:${step.command ?? ''}`}
|
||||
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>
|
||||
))}
|
||||
</ol>
|
||||
</PostInstallList>
|
||||
)}
|
||||
|
||||
<div className="flex items-center justify-between pt-2">
|
||||
|
||||
@@ -35,6 +35,7 @@ function CredentialRow({ label, value }: { readonly label: string; readonly valu
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function CredentialsStep({
|
||||
credentials,
|
||||
token,
|
||||
@@ -44,7 +45,7 @@ export default function CredentialsStep({
|
||||
onContinue,
|
||||
}: CredentialsStepProps) {
|
||||
const { t } = useTranslation();
|
||||
const [expiration, setExpiration] = useState<ExpirationId>('24hours');
|
||||
const [expiration, setExpiration] = useState<ExpirationId>('neverExpire');
|
||||
const [customDays, setCustomDays] = useState('');
|
||||
const [validationError, setValidationError] = useState<string | null>(null);
|
||||
|
||||
|
||||
@@ -32,22 +32,30 @@ function IntegrationGroup({
|
||||
key={integration.id}
|
||||
type="button"
|
||||
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-[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>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
export default function IntegrationStep({ onSelect }: IntegrationStepProps) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<IntegrationGroup mode="adapter" onSelect={onSelect} />
|
||||
<IntegrationGroup mode="hooks" onSelect={onSelect} />
|
||||
<IntegrationGroup mode="sdk" onSelect={onSelect} />
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -45,7 +45,7 @@ export default function Modal({
|
||||
};
|
||||
}, [isOpen]);
|
||||
|
||||
// Keydown listener — uses ref so it never changes identity
|
||||
// Keydown listener uses a ref for stable identity
|
||||
useEffect(() => {
|
||||
if (!isOpen) return;
|
||||
|
||||
|
||||
@@ -135,7 +135,7 @@ export default function Sidebar({ isOpen, onClose }: SidebarProps) {
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Backdrop overlay — mobile only */}
|
||||
{/* Mobile backdrop overlay */}
|
||||
{isOpen && (
|
||||
<div
|
||||
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 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 {
|
||||
readonly agentId: string;
|
||||
@@ -14,13 +16,15 @@ interface InstructionInput {
|
||||
readonly integration: IntegrationCatalogItem;
|
||||
readonly identity: AgentInstallIdentity;
|
||||
readonly baseUrl: string;
|
||||
readonly privateKey: string;
|
||||
readonly token: string;
|
||||
}
|
||||
|
||||
export interface InstallInstructions {
|
||||
readonly setup: string;
|
||||
readonly usage?: string;
|
||||
readonly verify?: string;
|
||||
readonly postInstall?: readonly string[];
|
||||
readonly postInstall?: PostInstallPlan;
|
||||
readonly secretDelivery: SecretDelivery;
|
||||
}
|
||||
|
||||
@@ -31,42 +35,97 @@ function shellQuoted(value: string, label: string): string {
|
||||
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,
|
||||
{ integration, identity, baseUrl }: InstructionInput,
|
||||
values: CommandValues,
|
||||
keyFile: string,
|
||||
tokenFile: 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,
|
||||
orgId: shellQuoted(identity.orgId, 'Organization ID'),
|
||||
agentId: shellQuoted(identity.agentId, 'Agent ID'),
|
||||
kid: shellQuoted(identity.kid, 'Key ID'),
|
||||
baseUrl: shellQuoted(baseUrl, 'API base URL'),
|
||||
};
|
||||
|
||||
if (language === 'go') {
|
||||
return [
|
||||
'go install github.com/Elydora-Infrastructure/Elydora-Go-SDK/cmd/elydora@latest',
|
||||
`elydora install --agent ${values.agent} --org-id ${values.orgId} --agent-id ${values.agentId} --kid ${values.kid} --base-url ${values.baseUrl}`,
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
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;
|
||||
const stem = credentialFileStem(identity.agentId);
|
||||
const command = shell === 'powershell'
|
||||
? powershellInstall(language, values, stem, privateKey, token)
|
||||
: posixInstall(language, values, stem, privateKey, token);
|
||||
const bootstrap = BOOTSTRAP[language];
|
||||
return bootstrap ? `${bootstrap}\n${command}` : command;
|
||||
}
|
||||
|
||||
function sdkSetup(language: SdkLanguage, input: InstructionInput): string {
|
||||
@@ -95,7 +154,7 @@ client.set_kid(${JSON.stringify(identity.kid)})`;
|
||||
}
|
||||
|
||||
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 {
|
||||
value, ok := os.LookupEnv(name)
|
||||
@@ -176,13 +235,14 @@ await client.submitOperation(operation);`;
|
||||
export function buildInstallInstructions(
|
||||
language: SdkLanguage,
|
||||
input: InstructionInput,
|
||||
shell: Shell = 'posix',
|
||||
): InstallInstructions {
|
||||
if (input.integration.mode === 'adapter') {
|
||||
if (input.integration.mode === 'hooks') {
|
||||
return {
|
||||
setup: adapterCommand(language, input),
|
||||
setup: hookInstallerCommand(language, input, shell),
|
||||
verify: language === 'node' ? 'npx @elydora/sdk status' : 'elydora status',
|
||||
postInstall: postInstallSteps(input.integration),
|
||||
secretDelivery: 'hidden-prompts',
|
||||
postInstall: input.integration.postInstall,
|
||||
secretDelivery: 'embedded',
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -5,35 +5,169 @@ import {
|
||||
|
||||
export { INTEGRATION_TYPES };
|
||||
|
||||
export type IntegrationMode = 'adapter' | 'sdk';
|
||||
export type PostInstallAction = 'review-hooks' | 'start-kiro';
|
||||
export type IntegrationMode = 'hooks' | 'sdk';
|
||||
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 {
|
||||
readonly id: IntegrationType;
|
||||
readonly name: string;
|
||||
readonly mode: IntegrationMode;
|
||||
readonly postInstall?: PostInstallAction;
|
||||
readonly postInstall?: PostInstallPlan;
|
||||
}
|
||||
|
||||
const INTEGRATION_METADATA: Record<
|
||||
IntegrationType,
|
||||
Omit<IntegrationCatalogItem, 'id'>
|
||||
> = {
|
||||
augment: { name: 'Augment Code', mode: 'adapter' },
|
||||
claudecode: { name: 'Claude Code', mode: 'adapter' },
|
||||
cline: { name: 'Cline', mode: 'adapter' },
|
||||
codex: { name: 'OpenAI Codex', mode: 'adapter', postInstall: 'review-hooks' },
|
||||
copilot: { name: 'GitHub Copilot CLI', mode: 'adapter' },
|
||||
cursor: { name: 'Cursor', mode: 'adapter' },
|
||||
droid: { name: 'Factory Droid', mode: 'adapter', postInstall: 'review-hooks' },
|
||||
gemini: { name: 'Gemini CLI', mode: 'adapter' },
|
||||
grok: { name: 'Grok Build', mode: 'adapter' },
|
||||
kimi: { name: 'Kimi CLI', mode: 'adapter', postInstall: 'review-hooks' },
|
||||
kirocli: { name: 'Kiro CLI', mode: 'adapter', postInstall: 'start-kiro' },
|
||||
kiroide: { name: 'Kiro IDE', mode: 'adapter' },
|
||||
letta: { name: 'Letta Code', mode: 'adapter' },
|
||||
opencode: { name: 'OpenCode', mode: 'adapter' },
|
||||
qwen: { name: 'Qwen Code', mode: 'adapter', postInstall: 'review-hooks' },
|
||||
augment: {
|
||||
name: 'Augment Code',
|
||||
mode: 'hooks',
|
||||
postInstall: {
|
||||
kind: 'sequence',
|
||||
steps: [{ key: 'startSession', command: 'auggie' }],
|
||||
},
|
||||
},
|
||||
claudecode: {
|
||||
name: 'Claude Code',
|
||||
mode: 'hooks',
|
||||
postInstall: {
|
||||
kind: 'sequence',
|
||||
steps: [{ key: 'openHooks', command: '/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' },
|
||||
gui: { name: 'GUI Agent', mode: 'sdk' },
|
||||
sdk: { name: 'SDK', mode: 'sdk' },
|
||||
@@ -43,6 +177,6 @@ const INTEGRATION_METADATA: Record<
|
||||
export const INTEGRATION_CATALOG: readonly IntegrationCatalogItem[] =
|
||||
INTEGRATION_TYPES.map((id) => ({ id, ...INTEGRATION_METADATA[id] }));
|
||||
|
||||
export const ADAPTER_INTEGRATION_IDS: ReadonlySet<IntegrationType> = new Set(
|
||||
INTEGRATION_CATALOG.filter(({ mode }) => mode === 'adapter').map(({ id }) => id),
|
||||
export const HOOK_INTEGRATION_IDS: ReadonlySet<IntegrationType> = new Set(
|
||||
INTEGRATION_CATALOG.filter(({ mode }) => mode === 'hooks').map(({ id }) => id),
|
||||
);
|
||||
@@ -330,13 +330,15 @@
|
||||
"connect": "Connect"
|
||||
},
|
||||
"integrationGroups": {
|
||||
"adapter": "CLI adapters",
|
||||
"hooks": "Native hooks",
|
||||
"sdk": "Custom SDK"
|
||||
},
|
||||
"hookBadge": "HOOKS",
|
||||
"integration": "Integration",
|
||||
"copySetup": "Copy setup command",
|
||||
"copyUsage": "Copy operation example",
|
||||
"copyVerify": "Copy verification command",
|
||||
"copyPostInstall": "Copy activation command: {{command}}",
|
||||
"copyPrivateKey": "Copy private key",
|
||||
"copyApiToken": "Copy API token",
|
||||
"copyFailed": "Copy failed.",
|
||||
@@ -346,9 +348,24 @@
|
||||
"record": "Record an operation",
|
||||
"verify": "Verify",
|
||||
"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",
|
||||
"shell": "Shell",
|
||||
"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."
|
||||
}
|
||||
},
|
||||
|
||||
@@ -330,13 +330,15 @@
|
||||
"connect": "连接"
|
||||
},
|
||||
"integrationGroups": {
|
||||
"adapter": "CLI 适配器",
|
||||
"hooks": "原生 Hooks",
|
||||
"sdk": "自定义 SDK"
|
||||
},
|
||||
"hookBadge": "HOOKS",
|
||||
"integration": "集成",
|
||||
"copySetup": "复制安装命令",
|
||||
"copyUsage": "复制操作示例",
|
||||
"copyVerify": "复制验证命令",
|
||||
"copyPostInstall": "复制激活命令:{{command}}",
|
||||
"copyPrivateKey": "复制私钥",
|
||||
"copyApiToken": "复制 API 令牌",
|
||||
"copyFailed": "复制失败。",
|
||||
@@ -346,9 +348,24 @@
|
||||
"record": "记录操作",
|
||||
"verify": "验证",
|
||||
"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 语言",
|
||||
"shell": "终端",
|
||||
"secretDelivery": {
|
||||
"hidden-prompts": "CLI 会在关闭终端回显后请求这两项凭据。",
|
||||
"embedded": "命令会把两项凭据写入仅所有者可读的文件,安装 hook 后立即删除。",
|
||||
"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.
|
||||
*
|
||||
* 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.
|
||||
*/
|
||||
/** BullMQ queue adapter implementing the MessageQueue interface. */
|
||||
|
||||
import { Queue as BullQueue } from 'bullmq';
|
||||
import type { ConnectionOptions } from 'bullmq';
|
||||
|
||||
@@ -1,24 +1,12 @@
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
/** Adapter interfaces for ElydoraOpenSource infrastructure services. */
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Database (replaces D1Database)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* A prepared (and optionally bound) SQL statement.
|
||||
* Mirrors D1PreparedStatement's interface exactly.
|
||||
*/
|
||||
/** A prepared (and optionally bound) SQL statement. */
|
||||
export interface PreparedStatement {
|
||||
/**
|
||||
* Bind positional parameters (? placeholders) to the statement.
|
||||
* Returns a new statement with the values set.
|
||||
*/
|
||||
/** Bind positional parameters (? placeholders) to the statement. */
|
||||
bind(...values: unknown[]): PreparedStatement;
|
||||
|
||||
/** Execute and return the first row, or null if no rows match. */
|
||||
@@ -31,18 +19,12 @@ export interface PreparedStatement {
|
||||
run(): Promise<{ success: boolean }>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Relational database adapter.
|
||||
* Mirrors the subset of D1Database methods used by Elydora services.
|
||||
*/
|
||||
/** Relational database adapter. */
|
||||
export interface Database {
|
||||
/** Prepare a SQL statement for execution. */
|
||||
prepare(sql: string): PreparedStatement;
|
||||
|
||||
/**
|
||||
* Execute multiple prepared statements atomically (within a transaction).
|
||||
* Mirrors D1Database.batch().
|
||||
*/
|
||||
/** Execute multiple prepared statements atomically (within a transaction). */
|
||||
batch(statements: PreparedStatement[]): Promise<{ results: unknown[]; success: boolean }[]>;
|
||||
}
|
||||
|
||||
@@ -73,10 +55,7 @@ export interface ObjectStoreHead {
|
||||
readonly httpMetadata?: { readonly contentType?: string };
|
||||
}
|
||||
|
||||
/**
|
||||
* Object/blob storage adapter.
|
||||
* Mirrors the subset of R2Bucket methods used by Elydora services.
|
||||
*/
|
||||
/** Object/blob storage adapter. */
|
||||
export interface ObjectStore {
|
||||
/** Upload an object to the store. */
|
||||
put(
|
||||
@@ -96,10 +75,7 @@ export interface ObjectStore {
|
||||
// Cache (replaces KVNamespace)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Key/value cache adapter.
|
||||
* Mirrors the subset of KVNamespace methods used by Elydora services.
|
||||
*/
|
||||
/** Key/value cache adapter. */
|
||||
export interface Cache {
|
||||
/** Retrieve a cached value, or null if not found / expired. */
|
||||
get(key: string): Promise<string | null>;
|
||||
@@ -112,10 +88,7 @@ export interface Cache {
|
||||
// Message queue (replaces Queue)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Message queue adapter.
|
||||
* Mirrors the subset of Cloudflare Queue methods used by Elydora services.
|
||||
*/
|
||||
/** Message queue adapter. */
|
||||
export interface MessageQueue {
|
||||
/** Enqueue a message under the caller's durable message ID. */
|
||||
send(messageId: string, body: unknown): Promise<void>;
|
||||
|
||||
@@ -1,10 +1,4 @@
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
/** MinIO / S3-compatible object storage adapter implementing the ObjectStore interface. */
|
||||
|
||||
import {
|
||||
S3Client,
|
||||
@@ -64,7 +58,7 @@ export class MinioAdapter implements ObjectStore {
|
||||
} else if (body instanceof Uint8Array) {
|
||||
bodyData = body;
|
||||
} else {
|
||||
// Web ReadableStream — collect into buffer
|
||||
// Web ReadableStream: collect into buffer
|
||||
const chunks: Uint8Array[] = [];
|
||||
const reader = body.getReader();
|
||||
for (;;) {
|
||||
|
||||
@@ -1,13 +1,4 @@
|
||||
/**
|
||||
* 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().
|
||||
*/
|
||||
/** PostgreSQL adapter implementing the Database interface. */
|
||||
|
||||
import pg from 'pg';
|
||||
import type { Database, PreparedStatement } from './interfaces.js';
|
||||
@@ -25,10 +16,7 @@ types.setTypeParser(20, (val: string) => Number(val));
|
||||
// Placeholder conversion
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Convert SQLite `?` positional placeholders to PostgreSQL `$N` style.
|
||||
* Only replaces `?` outside of SQL string literals (safe for all Elydora queries).
|
||||
*/
|
||||
/** Convert SQLite `?` positional placeholders to PostgreSQL `$N` style. */
|
||||
function convertPlaceholders(sql: string): string {
|
||||
let index = 0;
|
||||
return sql.replace(/\?/g, () => `$${++index}`);
|
||||
@@ -38,10 +26,7 @@ function convertPlaceholders(sql: string): string {
|
||||
// Statement implementation
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Internal PostgreSQL statement that implements PreparedStatement.
|
||||
* Stores the SQL and bound values; executes lazily on first/all/run/executeWith.
|
||||
*/
|
||||
/** Internal PostgreSQL statement that implements PreparedStatement. */
|
||||
class PostgresStatement implements PreparedStatement {
|
||||
constructor(
|
||||
private readonly pool: Pool,
|
||||
|
||||
@@ -1,9 +1,4 @@
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
/** Redis cache adapter implementing the Cache interface. */
|
||||
|
||||
import { Redis } from 'ioredis';
|
||||
import type { Cache } from './interfaces.js';
|
||||
|
||||
@@ -1,15 +1,4 @@
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
/** Internationalization (i18n) translation map. */
|
||||
|
||||
export type Lang = 'en' | 'zh';
|
||||
|
||||
@@ -29,6 +18,14 @@ const messages: Record<string, Record<Lang, string>> = {
|
||||
en: 'The signing key has been revoked.',
|
||||
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': {
|
||||
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',
|
||||
@@ -90,8 +87,8 @@ const messages: Record<string, Record<Lang, string>> = {
|
||||
zh: 'Bearer \u4ee4\u724c\u4e3a\u7a7a',
|
||||
},
|
||||
'auth.ttlMustBePositive': {
|
||||
en: 'ttl_seconds must be a positive number',
|
||||
zh: 'ttl_seconds \u5fc5\u987b\u4e3a\u6b63\u6570',
|
||||
en: 'ttl_seconds must be a whole number of seconds from 1 to 31536000',
|
||||
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',
|
||||
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)
|
||||
@@ -355,15 +360,7 @@ const messages: Record<string, Record<Lang, string>> = {
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
/** Retrieve a translated message by key. */
|
||||
export function getMessage(
|
||||
key: string,
|
||||
lang: Lang = 'en',
|
||||
@@ -378,12 +375,7 @@ export function getMessage(
|
||||
return msg;
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect the preferred language from an Accept-Language header value.
|
||||
*
|
||||
* Returns 'zh' if the header contains any Chinese locale indicator,
|
||||
* otherwise defaults to 'en'.
|
||||
*/
|
||||
/** Detect the preferred language from an Accept-Language header value. */
|
||||
export function detectLanguage(acceptLanguage: string | null | undefined): Lang {
|
||||
if (!acceptLanguage) return 'en';
|
||||
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 { serve } from '@hono/node-server';
|
||||
@@ -90,7 +88,7 @@ app.use('/*', async (c, next) => {
|
||||
await next();
|
||||
});
|
||||
|
||||
// CORS — always restrict to the configured allowlist
|
||||
// CORS: always restrict to the configured allowlist
|
||||
app.use(
|
||||
'/*',
|
||||
cors({
|
||||
|
||||
@@ -1,16 +1,4 @@
|
||||
/**
|
||||
* 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
|
||||
*/
|
||||
/** Better Auth configuration for ElydoraOpenSource. */
|
||||
|
||||
import { betterAuth } from 'better-auth';
|
||||
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({
|
||||
@@ -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) {
|
||||
|
||||
@@ -1,28 +1,12 @@
|
||||
/**
|
||||
* 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)
|
||||
*/
|
||||
/** Authentication middleware for opaque API tokens and Better Auth sessions. */
|
||||
|
||||
import type { MiddlewareHandler } from 'hono';
|
||||
import type { RbacRole } from '../shared/index.js';
|
||||
import type { Env, AppVariables } from '../types.js';
|
||||
import { AppError } from './error-handler.js';
|
||||
import { 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<{
|
||||
Bindings: Env;
|
||||
Variables: AppVariables;
|
||||
@@ -32,6 +16,19 @@ export const authMiddleware: MiddlewareHandler<{
|
||||
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']>>;
|
||||
|
||||
try {
|
||||
@@ -57,6 +54,7 @@ export const authMiddleware: MiddlewareHandler<{
|
||||
c.set('org_id', user.org_id ?? '');
|
||||
c.set('role', ((user.role ?? 'readonly_investigator') as RbacRole));
|
||||
c.set('actor', user.id);
|
||||
c.set('auth_token_type', 'session');
|
||||
|
||||
await next();
|
||||
};
|
||||
@@ -1,15 +1,4 @@
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
/** Global error handler middleware. */
|
||||
|
||||
import type { ErrorHandler } from 'hono';
|
||||
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 type { Lang } from '../i18n/messages.js';
|
||||
|
||||
/**
|
||||
* Maps each ErrorCode to its corresponding i18n message key.
|
||||
*
|
||||
* This is used when an AppError is thrown with only an error code
|
||||
* (no explicit message or i18n key), so the global error handler
|
||||
* can still resolve a translated default message.
|
||||
*/
|
||||
/** Maps each ErrorCode to its corresponding i18n message key. */
|
||||
const ERROR_CODE_TO_I18N_KEY: Record<ErrorCode, string> = {
|
||||
INVALID_SIGNATURE: 'error.invalidSignature',
|
||||
UNKNOWN_AGENT: 'error.unknownAgent',
|
||||
KEY_REVOKED: 'error.keyRevoked',
|
||||
KEY_RETIRED: 'error.keyRetired',
|
||||
AGENT_FROZEN: 'error.agentFrozen',
|
||||
AGENT_REVOKED: 'error.agentRevoked',
|
||||
TTL_EXPIRED: 'error.ttlExpired',
|
||||
REPLAY_DETECTED: 'error.replayDetected',
|
||||
PREV_HASH_MISMATCH: 'error.prevHashMismatch',
|
||||
@@ -42,16 +27,7 @@ const ERROR_CODE_TO_I18N_KEY: Record<ErrorCode, string> = {
|
||||
VALIDATION_ERROR: 'error.validationError',
|
||||
};
|
||||
|
||||
/**
|
||||
* Custom error class that carries an HTTP status and Elydora error code.
|
||||
*
|
||||
* Supports three modes:
|
||||
* 1. Code-only: `new AppError(status, code)` — uses ErrorCode default message
|
||||
* 2. i18n key: `new AppError(status, code, { key: 'msg.key', params: {...} })` —
|
||||
* the message is resolved at response time from the translation map
|
||||
* 3. Raw string: `new AppError(status, code, 'raw message')` — message is used as-is
|
||||
* (should be avoided in new code; prefer mode 2)
|
||||
*/
|
||||
/** Custom error class that carries an HTTP status and Elydora error code. */
|
||||
export class AppError extends Error {
|
||||
public readonly statusCode: number;
|
||||
public readonly errorCode: ErrorCode;
|
||||
@@ -68,13 +44,13 @@ export class AppError extends Error {
|
||||
details?: Record<string, unknown>,
|
||||
) {
|
||||
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));
|
||||
this.messageKey = messageOrOpts.key;
|
||||
this.messageParams = messageOrOpts.params;
|
||||
this.usesDefaultMessage = false;
|
||||
} else if (messageOrOpts === undefined) {
|
||||
// Code-only mode — will be resolved via ERROR_CODE_TO_I18N_KEY
|
||||
// Code-only mode: will be resolved via ERROR_CODE_TO_I18N_KEY
|
||||
super(ERROR_CODES[errorCode]);
|
||||
this.usesDefaultMessage = true;
|
||||
} else {
|
||||
@@ -88,15 +64,7 @@ export class AppError extends Error {
|
||||
this.details = details;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the user-facing message for a given language.
|
||||
*
|
||||
* Resolution order:
|
||||
* 1. If a messageKey was provided, use that with the translation map.
|
||||
* 2. If the error used the default ErrorCode message, translate via
|
||||
* the ERROR_CODE_TO_I18N_KEY mapping.
|
||||
* 3. Otherwise return the raw message string.
|
||||
*/
|
||||
/** Resolve the user-facing message for a given language. */
|
||||
resolveMessage(lang: Lang): string {
|
||||
if (this.messageKey) {
|
||||
return getMessage(this.messageKey, lang, this.messageParams);
|
||||
@@ -111,12 +79,7 @@ export class AppError extends Error {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
/** Build a standardised error response body. */
|
||||
export function buildErrorResponse(
|
||||
code: ErrorCode,
|
||||
requestId: string,
|
||||
@@ -139,12 +102,7 @@ export function buildErrorResponse(
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
/** Hono error handler that converts all errors into the ErrorResponse format. */
|
||||
export const globalErrorHandler: ErrorHandler<{
|
||||
Bindings: Env;
|
||||
Variables: AppVariables;
|
||||
|
||||
@@ -1,11 +1,4 @@
|
||||
/**
|
||||
* 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')`.
|
||||
*/
|
||||
/** i18n middleware: language detection from Accept-Language header. */
|
||||
|
||||
import type { MiddlewareHandler } from 'hono';
|
||||
import type { Env, AppVariables } from '../types.js';
|
||||
|
||||
@@ -1,25 +1,11 @@
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
/** Role-based access control (RBAC) middleware. */
|
||||
|
||||
import type { MiddlewareHandler } from 'hono';
|
||||
import type { RbacRole } from '../shared/index.js';
|
||||
import type { Env, AppVariables } from '../types.js';
|
||||
import { AppError } from './error-handler.js';
|
||||
|
||||
/**
|
||||
* Privilege level map. Higher number = more privilege.
|
||||
* integration_engineer is a special role with limited admin capabilities
|
||||
* for agent management only.
|
||||
*/
|
||||
/** Privilege level map. */
|
||||
const ROLE_LEVEL: Record<RbacRole, number> = {
|
||||
readonly_investigator: 10,
|
||||
integration_engineer: 20,
|
||||
@@ -28,21 +14,12 @@ const ROLE_LEVEL: Record<RbacRole, number> = {
|
||||
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 {
|
||||
return ROLE_LEVEL[userRole] >= ROLE_LEVEL[requiredRole];
|
||||
}
|
||||
|
||||
/**
|
||||
* Create an RBAC middleware that requires a minimum role.
|
||||
*
|
||||
* Usage:
|
||||
* app.post('/v1/agents/:id/freeze', requireRole('security_admin'), handler);
|
||||
*
|
||||
* @param minimumRole - The minimum RBAC role required to access the endpoint
|
||||
*/
|
||||
/** Create an RBAC middleware that requires a minimum role. */
|
||||
export function requireRole(minimumRole: RbacRole): MiddlewareHandler<{
|
||||
Bindings: Env;
|
||||
Variables: AppVariables;
|
||||
|
||||
@@ -1,10 +1,4 @@
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
/** Request ID middleware. */
|
||||
|
||||
import type { MiddlewareHandler } from 'hono';
|
||||
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 type { Env, AppVariables } from '../types.js';
|
||||
|
||||
@@ -1,10 +1,4 @@
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
/** Audit routes: query the tamper-evident audit log. */
|
||||
|
||||
import { Hono } from 'hono';
|
||||
import type { Env, AppVariables } from '../types.js';
|
||||
@@ -19,7 +13,7 @@ const audit = new Hono<{ Bindings: Env; Variables: AppVariables }>();
|
||||
audit.use('/*', authMiddleware);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// POST /v1/audit/query — Query the audit log
|
||||
// POST /v1/audit/query: Query the audit log
|
||||
// ---------------------------------------------------------------------------
|
||||
audit.post(
|
||||
'/query',
|
||||
|
||||
@@ -1,16 +1,10 @@
|
||||
/**
|
||||
* 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).
|
||||
*/
|
||||
/** Auth routes: registration, login, profile retrieval, and token refresh. */
|
||||
|
||||
import { Hono } from 'hono';
|
||||
import type { Env, AppVariables } from '../types.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 { createAuth } from '../lib/auth.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 }>();
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 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) => {
|
||||
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) => {
|
||||
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) => {
|
||||
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) => {
|
||||
const betterAuthInstance = createAuth(
|
||||
@@ -152,7 +146,7 @@ auth.post('/refresh', authMiddleware, async (c) => {
|
||||
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
|
||||
const session = await betterAuthInstance.api.getSession({
|
||||
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) => {
|
||||
if (c.get('auth_token_type') !== 'session') {
|
||||
throw new AppError(400, 'VALIDATION_ERROR', { key: 'auth.issueRequiresSession' });
|
||||
}
|
||||
|
||||
const body = await c.req.json();
|
||||
const { ttl_seconds } = body;
|
||||
|
||||
if (ttl_seconds !== null && ttl_seconds !== undefined) {
|
||||
if (typeof ttl_seconds !== 'number' || ttl_seconds <= 0) {
|
||||
const lang = c.get('lang') ?? 'en';
|
||||
return c.json({ error: { code: 'VALIDATION_ERROR', message: getMessage('auth.ttlMustBeNumber', lang), request_id: '' } }, 400);
|
||||
if (typeof ttl_seconds !== 'number') {
|
||||
throw new AppError(400, 'VALIDATION_ERROR', { key: 'auth.ttlMustBeNumber' });
|
||||
}
|
||||
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
|
||||
// lifetime configured in the auth instance (7 days by default).
|
||||
// The ttl_seconds parameter is accepted for API compatibility but
|
||||
// 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 orgId = c.get('org_id');
|
||||
if (!orgId) {
|
||||
throw new AppError(409, 'VALIDATION_ERROR', { key: 'auth.organizationRequiredForApiToken' });
|
||||
}
|
||||
|
||||
const sessionData = session.session as { token?: string; expiresAt?: Date };
|
||||
const token = sessionData.token ?? '';
|
||||
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);
|
||||
const issued = await issueApiToken(c.env.ELYDORA_DB, c.get('actor'), orgId, ttl_seconds ?? null);
|
||||
return c.json({ token: issued.token, expires_at: issued.expires_at }, 200);
|
||||
});
|
||||
|
||||
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 type { Env, AppVariables } from '../types.js';
|
||||
@@ -14,7 +12,7 @@ const epochs = new Hono<{ Bindings: Env; Variables: AppVariables }>();
|
||||
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) => {
|
||||
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(
|
||||
'/: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 type { Env, AppVariables } from '../types.js';
|
||||
@@ -16,7 +14,7 @@ const exports_ = new Hono<{ Bindings: Env; Variables: AppVariables }>();
|
||||
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) => {
|
||||
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(
|
||||
'/',
|
||||
@@ -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(
|
||||
'/: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(
|
||||
'/:export_id/download',
|
||||
|
||||
@@ -1,13 +1,4 @@
|
||||
/**
|
||||
* 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).
|
||||
*/
|
||||
/** JWKS route: public key discovery endpoint. */
|
||||
|
||||
import { Hono } from 'hono';
|
||||
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 }>();
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// GET /.well-known/elydora/jwks.json — Retrieve the platform JWKS
|
||||
// GET /.well-known/elydora/jwks.json: Retrieve the platform JWKS
|
||||
// ---------------------------------------------------------------------------
|
||||
jwks.get('/', async (c) => {
|
||||
// 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 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 }>();
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// POST /v1/operations — Submit a signed EOR
|
||||
// POST /v1/operations: Submit a signed EOR
|
||||
// ---------------------------------------------------------------------------
|
||||
// Operation submission uses the EOR's embedded signature for authentication
|
||||
// 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(
|
||||
'/: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(
|
||||
'/:operation_id/verify',
|
||||
|
||||
@@ -1,8 +1,4 @@
|
||||
/**
|
||||
* Epoch scheduler — runs every 5 minutes to create Merkle tree rollups.
|
||||
*
|
||||
* Run as a separate Node.js process: node dist/scheduler.js
|
||||
*/
|
||||
/** Epoch scheduler: runs every 5 minutes to create Merkle tree rollups. */
|
||||
|
||||
import 'dotenv/config';
|
||||
import cron from 'node-cron';
|
||||
|
||||
@@ -1,9 +1,4 @@
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
/** Agent service: business logic for agent lifecycle management. */
|
||||
|
||||
import type {
|
||||
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.
|
||||
*
|
||||
* 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.
|
||||
*/
|
||||
/** Audit service: business logic for querying the tamper-evident audit log. */
|
||||
|
||||
import type { Operation, AuditQueryRequest, AuditQueryResponse } 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.
|
||||
*
|
||||
* 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.
|
||||
*/
|
||||
/** Auth service: password hashing and user profile retrieval. */
|
||||
|
||||
import { AppError } from '../middleware/error-handler.js';
|
||||
import type { RbacRole } from '../shared/index.js';
|
||||
|
||||
@@ -1,11 +1,4 @@
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
/** Epoch service: business logic for retrieving epoch root records. */
|
||||
|
||||
import type { Epoch, EER, GetEpochResponse, ListEpochsResponse } from '../shared/index.js';
|
||||
import { AppError } from '../middleware/error-handler.js';
|
||||
@@ -82,13 +75,7 @@ export async function getEpoch(
|
||||
// Create epoch
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
/** Create a new epoch by building a Merkle tree over operations in a time window, signing the Epoch Record, and p */
|
||||
export async function createEpoch(
|
||||
db: Database,
|
||||
r2: ObjectStore,
|
||||
@@ -97,7 +84,7 @@ export async function createEpoch(
|
||||
startTime: number,
|
||||
endTime: number,
|
||||
): 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
|
||||
.prepare(
|
||||
'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.
|
||||
*
|
||||
* 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.
|
||||
*/
|
||||
/** Export service: business logic for compliance export job management. */
|
||||
|
||||
import type { Export, CreateExportRequest, CreateExportResponse, GetExportResponse, ListExportsResponse } from '../shared/index.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 @@
|
||||
/**
|
||||
* 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 type { Operation, Receipt, GetOperationResponse } from '../shared/index.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';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Constants
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** 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 { submitOperation } from './operation-submit.js';
|
||||
export { verifyOperation } from './operation-verify.js';
|
||||
|
||||
export async function getOperation(
|
||||
db: Database,
|
||||
@@ -354,7 +35,7 @@ export async function getOperation(
|
||||
payload = (eor.payload as Record<string, unknown>) ?? undefined;
|
||||
}
|
||||
} 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
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
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.',
|
||||
UNKNOWN_AGENT: 'The specified agent does not exist.',
|
||||
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_REVOKED: 'The agent has been revoked.',
|
||||
TTL_EXPIRED: 'The operation TTL has expired.',
|
||||
REPLAY_DETECTED: 'A duplicate nonce was detected (replay attack).',
|
||||
PREV_HASH_MISMATCH: 'The previous chain hash does not match.',
|
||||
|
||||
@@ -56,7 +56,9 @@ export type ErrorCode =
|
||||
| 'INVALID_SIGNATURE'
|
||||
| 'UNKNOWN_AGENT'
|
||||
| 'KEY_REVOKED'
|
||||
| 'KEY_RETIRED'
|
||||
| 'AGENT_FROZEN'
|
||||
| 'AGENT_REVOKED'
|
||||
| 'TTL_EXPIRED'
|
||||
| 'REPLAY_DETECTED'
|
||||
| 'PREV_HASH_MISMATCH'
|
||||
|
||||
@@ -1,9 +1,4 @@
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
/** Elydora Operation Record (EOR) The fundamental unit of auditable activity in the Elydora protocol. */
|
||||
export interface EOR {
|
||||
/** Protocol version, always "1.0" */
|
||||
readonly op_version: '1.0';
|
||||
@@ -37,11 +32,7 @@ export interface EOR {
|
||||
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 {
|
||||
/** Previous chain hash (base64url) */
|
||||
readonly prev_ech: string;
|
||||
@@ -55,11 +46,7 @@ export interface ECH {
|
||||
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 {
|
||||
/** Receipt version */
|
||||
readonly receipt_version: string;
|
||||
@@ -87,11 +74,7 @@ export interface EAR {
|
||||
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 {
|
||||
/** Unique epoch identifier */
|
||||
readonly epoch_id: string;
|
||||
|
||||
@@ -1,10 +1,4 @@
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
/** Environment bindings for the ElydoraOpenSource Node.js server. */
|
||||
|
||||
import type { Database, ObjectStore, Cache, MessageQueue } from './adapters/interfaces.js';
|
||||
|
||||
@@ -46,9 +40,7 @@ export interface Env {
|
||||
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 {
|
||||
/** UUIDv7 request identifier injected by the request-id middleware */
|
||||
request_id: string;
|
||||
@@ -62,6 +54,9 @@ export interface AppVariables {
|
||||
/** Actor identifier extracted from the verified session */
|
||||
actor: string;
|
||||
|
||||
/** Credential kind that authenticated the request */
|
||||
auth_token_type: 'api' | 'session';
|
||||
|
||||
/** Detected language from Accept-Language header (set by i18n middleware) */
|
||||
lang: import('./i18n/messages.js').Lang;
|
||||
}
|
||||
@@ -1,9 +1,4 @@
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
/** Cryptographic utilities for the Elydora API. */
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Base64url helpers (RFC 4648 section 5)
|
||||
@@ -52,11 +47,7 @@ export async function sha256Base64url(data: BufferSource | string): Promise<stri
|
||||
// Ed25519 signature verification
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Import a raw Ed25519 public key (32 bytes) as a CryptoKey.
|
||||
*
|
||||
* @param publicKeyBytes - 32-byte raw Ed25519 public key
|
||||
*/
|
||||
/** Import a raw Ed25519 public key (32 bytes) as a CryptoKey. */
|
||||
export async function importEd25519PublicKey(publicKeyBytes: Uint8Array): Promise<CryptoKey> {
|
||||
return crypto.subtle.importKey(
|
||||
'raw',
|
||||
@@ -67,14 +58,7 @@ export async function importEd25519PublicKey(publicKeyBytes: Uint8Array): Promis
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
*/
|
||||
/** Verify an Ed25519 signature over data. */
|
||||
export async function verifyEd25519Signature(
|
||||
publicKeyBytes: Uint8Array,
|
||||
signatureBytes: Uint8Array,
|
||||
@@ -97,15 +81,7 @@ export async function verifyEd25519Signature(
|
||||
// Ed25519 signing (server-side receipt signing)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* 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> } }
|
||||
* }
|
||||
*/
|
||||
/** PKCS8 DER prefix for wrapping a 32-byte Ed25519 seed into a valid PKCS8 key. */
|
||||
const ED25519_PKCS8_PREFIX = new Uint8Array([
|
||||
0x30, 0x2e, // SEQUENCE (46 bytes)
|
||||
0x02, 0x01, 0x00, // INTEGER 0
|
||||
@@ -123,14 +99,7 @@ function wrapSeedAsPkcs8(seed: Uint8Array): Uint8Array {
|
||||
return pkcs8;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
*/
|
||||
/** Import a raw Ed25519 private key for signing. */
|
||||
export async function importEd25519PrivateKey(privateKeyBase64url: string): Promise<CryptoKey> {
|
||||
const seed = base64urlDecode(privateKeyBase64url);
|
||||
const pkcs8 = wrapSeedAsPkcs8(seed);
|
||||
@@ -144,13 +113,7 @@ export async function importEd25519PrivateKey(privateKeyBase64url: string): Prom
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
*/
|
||||
/** Sign data with an Ed25519 private key. */
|
||||
export async function signEd25519(
|
||||
privateKeyBase64url: string,
|
||||
data: Uint8Array,
|
||||
@@ -164,12 +127,7 @@ export async function signEd25519(
|
||||
return base64urlEncode(new Uint8Array(signature));
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 the public key from an Ed25519 private key (for JWKS). */
|
||||
export async function deriveEd25519PublicKey(privateKeyBase64url: string): Promise<string> {
|
||||
const seed = base64urlDecode(privateKeyBase64url);
|
||||
const pkcs8 = wrapSeedAsPkcs8(seed);
|
||||
@@ -178,7 +136,7 @@ export async function deriveEd25519PublicKey(privateKeyBase64url: string): Promi
|
||||
'pkcs8',
|
||||
pkcs8 as unknown as Uint8Array<ArrayBuffer>,
|
||||
{ name: 'Ed25519' },
|
||||
true, // extractable — needed to export the public key
|
||||
true, // extractable: needed to export the public key
|
||||
['sign'],
|
||||
);
|
||||
// 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)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* 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
|
||||
*/
|
||||
/** Canonicalize a JSON value according to JCS (RFC 8785). */
|
||||
export function jcsCanonicalise(value: unknown): string {
|
||||
if (value === null || value === undefined) {
|
||||
return 'null';
|
||||
@@ -237,13 +187,7 @@ export function jcsCanonicalise(value: unknown): string {
|
||||
// Chain hash computation
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* 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 '|'.
|
||||
*/
|
||||
/** Compute the chain hash for an operation. */
|
||||
export async function computeChainHash(
|
||||
prevChainHash: string,
|
||||
payloadHash: string,
|
||||
@@ -258,11 +202,7 @@ export async function computeChainHash(
|
||||
// Receipt hash computation
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Compute the hash of an EAR receipt for signing.
|
||||
*
|
||||
* Hashes the canonicalized receipt fields (excluding the signature fields themselves).
|
||||
*/
|
||||
/** Compute the hash of an EAR receipt for signing. */
|
||||
export async function computeReceiptHash(receiptFields: {
|
||||
receipt_version: string;
|
||||
receipt_id: string;
|
||||
|
||||
@@ -1,10 +1,4 @@
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
/** SHA-256 Merkle tree implementation for epoch rollups. */
|
||||
|
||||
import { sha256Base64url, base64urlDecode } from './crypto.js';
|
||||
|
||||
@@ -38,10 +32,7 @@ export interface MerkleProof {
|
||||
// Internal helper
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Compute the parent hash from two child hashes.
|
||||
* Decodes both base64url hashes to raw bytes, concatenates, then SHA-256.
|
||||
*/
|
||||
/** Compute the parent hash from two child hashes. */
|
||||
async function hashPair(left: string, right: string): Promise<string> {
|
||||
const leftBytes = base64urlDecode(left);
|
||||
const rightBytes = base64urlDecode(right);
|
||||
@@ -55,12 +46,7 @@ async function hashPair(left: string, right: string): Promise<string> {
|
||||
// Build
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
/** Build a Merkle tree from leaf hashes and their corresponding operation IDs. */
|
||||
export async function buildMerkleTree(
|
||||
leafHashes: string[],
|
||||
operationIds: string[],
|
||||
@@ -103,11 +89,7 @@ export async function buildMerkleTree(
|
||||
// Proof generation
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Generate a Merkle inclusion proof for a given leaf hash.
|
||||
*
|
||||
* Returns null if the leaf is not found in the tree.
|
||||
*/
|
||||
/** Generate a Merkle inclusion proof for a given leaf hash. */
|
||||
export function getMerkleProof(
|
||||
tree: MerkleTree,
|
||||
leafHash: string,
|
||||
@@ -142,9 +124,7 @@ export function getMerkleProof(
|
||||
// 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> {
|
||||
let current = proof.leaf;
|
||||
|
||||
|
||||
@@ -1,11 +1,4 @@
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
/** Cursor-based pagination helpers. */
|
||||
|
||||
import { base64urlDecode, base64urlEncode } from './crypto.js';
|
||||
|
||||
@@ -17,24 +10,14 @@ export interface CursorPayload {
|
||||
readonly id: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Encode a cursor payload into an opaque cursor string.
|
||||
*
|
||||
* @param payload - The cursor data to encode
|
||||
* @returns An opaque base64url-encoded cursor string
|
||||
*/
|
||||
/** Encode a cursor payload into an opaque cursor string. */
|
||||
export function encodeCursor(payload: CursorPayload): string {
|
||||
const json = JSON.stringify(payload);
|
||||
const bytes = new TextEncoder().encode(json);
|
||||
return base64urlEncode(bytes);
|
||||
}
|
||||
|
||||
/**
|
||||
* Decode an opaque cursor string back into its payload.
|
||||
*
|
||||
* @param cursor - The opaque cursor string
|
||||
* @returns The decoded cursor payload, or null if invalid
|
||||
*/
|
||||
/** Decode an opaque cursor string back into its payload. */
|
||||
export function decodeCursor(cursor: string): CursorPayload | null {
|
||||
try {
|
||||
const bytes = base64urlDecode(cursor);
|
||||
|
||||
@@ -1,15 +1,4 @@
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
/** Branded PDF generator for Elydora compliance exports. */
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Types
|
||||
@@ -46,15 +35,15 @@ const CW = PW - ML - MR; // 500pt content width
|
||||
|
||||
// Header bar
|
||||
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
|
||||
const FTR_LINE = 44; // y of separator line
|
||||
const FTR_TEXT = 28; // y of footer text baseline
|
||||
|
||||
// Content area
|
||||
const CT = HDR_Y - 20; // 716 — content top
|
||||
const CB = FTR_LINE + 12; // 56 — content bottom
|
||||
const CT = HDR_Y - 20; // 716: content top
|
||||
const CB = FTR_LINE + 12; // 56 : content bottom
|
||||
|
||||
// Operations table
|
||||
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`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Draw the Elydora "E" logo mark.
|
||||
* SVG source: M3 2H21V7H8V10H17V15H8V18H21V23H3V2Z (viewBox 0 0 24 24)
|
||||
* Transform flips Y for PDF coordinate system.
|
||||
*/
|
||||
/** Draw the Elydora "E" logo mark. */
|
||||
function drawLogo(x: number, y: number, size: number, rgb: string): string {
|
||||
const sc = size / 24;
|
||||
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). */
|
||||
@@ -321,7 +306,7 @@ function assemblePDF(pageStreams: string[]): Uint8Array {
|
||||
// Obj 1: Catalog
|
||||
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('');
|
||||
|
||||
// Obj 3–5: Built-in fonts
|
||||
|
||||
@@ -1,49 +1,27 @@
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
/** RFC 3161 Timestamping Authority (TSA) client utilities. */
|
||||
|
||||
/** Default public TSA endpoint (Sectigo / Comodo). */
|
||||
export const DEFAULT_TSA_URL = 'http://timestamp.sectigo.com';
|
||||
|
||||
/**
|
||||
* 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
|
||||
* }
|
||||
*/
|
||||
/** Build a minimal RFC 3161 TimeStampReq (DER-encoded, 59 bytes). */
|
||||
export function buildTimeStampReq(sha256Hash: Uint8Array): Uint8Array {
|
||||
if (sha256Hash.length !== 32) {
|
||||
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([
|
||||
0x30, 0x39, // SEQUENCE (57 bytes)
|
||||
0x02, 0x01, 0x01, // INTEGER 1 (version v1)
|
||||
0x30, 0x31, // SEQUENCE (49 bytes) — MessageImprint
|
||||
0x30, 0x0d, // SEQUENCE (13 bytes) — AlgorithmIdentifier
|
||||
0x30, 0x31, // SEQUENCE (49 bytes): MessageImprint
|
||||
0x30, 0x0d, // SEQUENCE (13 bytes): AlgorithmIdentifier
|
||||
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)
|
||||
0x05, 0x00, // NULL (parameters)
|
||||
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]);
|
||||
|
||||
// Assemble: prefix + hash + suffix = 24 + 32 + 3 = 59 bytes
|
||||
@@ -55,13 +33,7 @@ export function buildTimeStampReq(sha256Hash: Uint8Array): Uint8Array {
|
||||
return req;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
*/
|
||||
/** Submit a SHA-256 hash to a TSA and return the raw DER TimeStampResp. */
|
||||
export async function requestTimestamp(
|
||||
sha256Hash: Uint8Array,
|
||||
tsaUrl: string = DEFAULT_TSA_URL,
|
||||
|
||||
@@ -1,24 +1,6 @@
|
||||
/**
|
||||
* 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+.
|
||||
*/
|
||||
/** UUIDv7 generator conforming to RFC 9562. */
|
||||
|
||||
/**
|
||||
* 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)
|
||||
*/
|
||||
/** Generate a UUIDv7 string. */
|
||||
export function generateUUIDv7(): string {
|
||||
const now = Date.now();
|
||||
const bytes = new Uint8Array(16);
|
||||
@@ -46,9 +28,7 @@ export function generateUUIDv7(): string {
|
||||
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 {
|
||||
const hex = Array.from(bytes)
|
||||
.map((b) => b.toString(16).padStart(2, '0'))
|
||||
|
||||
@@ -1,8 +1,4 @@
|
||||
/**
|
||||
* BullMQ queue worker — processes operation, export, and TSA jobs.
|
||||
*
|
||||
* Run as a separate Node.js process: node dist/worker.js
|
||||
*/
|
||||
/** BullMQ queue worker: processes operation, export, and TSA jobs. */
|
||||
|
||||
import 'dotenv/config';
|
||||
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
|
||||
|
||||
# ───────────────────────────────────────────────────────────
|
||||
# Elydora Open Source — One-click deployment
|
||||
# Elydora Open Source installer
|
||||
# ───────────────────────────────────────────────────────────
|
||||
|
||||
printf '\n'
|
||||
@@ -50,7 +50,7 @@ ok "All prerequisites satisfied."
|
||||
cd "$(dirname "$0")/.."
|
||||
|
||||
if [ -f .env ]; then
|
||||
info "Existing .env found — skipping secret generation."
|
||||
info "Existing .env found; skipping secret generation."
|
||||
else
|
||||
info "Generating .env from .env.example..."
|
||||
|
||||
|
||||
@@ -31,3 +31,4 @@ Thumbs.db
|
||||
.env.local
|
||||
*.pem
|
||||
*.key
|
||||
/elydora
|
||||
+58
-25
@@ -5,11 +5,13 @@ Official Go SDK for the [Elydora](https://elydora.com) tamper-evident audit plat
|
||||
## Installation
|
||||
|
||||
```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.
|
||||
|
||||
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
|
||||
|
||||
```go
|
||||
@@ -19,19 +21,16 @@ import (
|
||||
"fmt"
|
||||
"log"
|
||||
|
||||
elydora "github.com/Elydora-Infrastructure/Elydora-Go-SDK"
|
||||
elydora "github.com/Elydora-Infrastructure/Elydora-Go-SDK/v2"
|
||||
)
|
||||
|
||||
func main() {
|
||||
// Initialize the client with your API token.
|
||||
// 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
|
||||
// Create client with an API token issued in the Console
|
||||
client, err := elydora.NewClient(&elydora.Config{
|
||||
OrgID: "org-123",
|
||||
AgentID: "my-agent-id",
|
||||
PrivateKey: "<base64url-encoded-ed25519-seed>",
|
||||
Token: "your-api-token",
|
||||
Token: "<api-token>",
|
||||
})
|
||||
if err != nil {
|
||||
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.
|
||||
|
||||
```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 \
|
||||
--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.
|
||||
|
||||
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.
|
||||
|
||||
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 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.
|
||||
|
||||
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 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 version` | Print the CLI version |
|
||||
|
||||
### Supported Agents
|
||||
|
||||
@@ -154,24 +158,24 @@ client.SetToken("new-api-token")
|
||||
|
||||
### 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
|
||||
// Sign up (Better Auth) — use the console or call directly:
|
||||
// POST /api/auth/sign-up/email { email, password, name }
|
||||
//
|
||||
// Sign in (Better Auth) — get a session token:
|
||||
// POST /api/auth/sign-in/email { email, password }
|
||||
//
|
||||
// Issue a long-lived API token from an active session:
|
||||
// Get the authenticated user and current organization
|
||||
me, err := client.GetMe()
|
||||
// me.User.Email, me.User.Role, me.CurrentOrganization.OrgID
|
||||
|
||||
// Issue an API token; this call needs a client whose Token is a Console session token
|
||||
tokenResp, err := client.IssueApiToken(&elydora.IssueApiTokenRequest{
|
||||
TTLSeconds: &ttlSeconds,
|
||||
})
|
||||
client.SetToken(tokenResp.Token)
|
||||
|
||||
// Update the token on an existing client instance at runtime
|
||||
client.SetToken("new-api-token")
|
||||
// Rotate the installed API token; the response carries the replacement
|
||||
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
|
||||
|
||||
```go
|
||||
@@ -211,8 +215,11 @@ agent, err := client.RegisterAgent(&elydora.RegisterAgentRequest{
|
||||
// Get agent details
|
||||
details, err := client.GetAgent(agentID)
|
||||
|
||||
// Change the integration type
|
||||
updated, err := client.UpdateAgent(agentID, elydora.IntegrationTypeClaudecode)
|
||||
|
||||
// Freeze an agent
|
||||
err := client.FreezeAgent(agentID, "security review")
|
||||
frozen, err := client.FreezeAgent(agentID, "security review")
|
||||
|
||||
// Revoke a key
|
||||
err := client.RevokeKey(agentID, kid, "key rotation")
|
||||
@@ -221,7 +228,7 @@ err := client.RevokeKey(agentID, kid, "key rotation")
|
||||
agents, err := client.ListAgents()
|
||||
|
||||
// Unfreeze a previously frozen agent
|
||||
err := client.UnfreezeAgent(agentID, "review complete")
|
||||
unfrozen, err := client.UnfreezeAgent(agentID, "review complete")
|
||||
|
||||
// Delete an agent permanently
|
||||
deleted, err := client.DeleteAgent(agentID)
|
||||
@@ -262,6 +269,25 @@ detail, err := client.GetExport(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
|
||||
|
||||
```go
|
||||
@@ -273,13 +299,17 @@ jwks, err := client.GetJWKS()
|
||||
```go
|
||||
// Check API health (no authentication required, does not need a client)
|
||||
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
|
||||
|
||||
```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"
|
||||
```
|
||||
|
||||
@@ -291,8 +321,11 @@ elydora.GenesisChainHash // "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"
|
||||
| `KeyStatus` | `KeyStatusActive`, `KeyStatusRetired`, `KeyStatusRevoked` |
|
||||
| `ExportStatus` | `ExportStatusQueued`, `ExportStatusRunning`, `ExportStatusDone`, `ExportStatusFailed` |
|
||||
| `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` |
|
||||
| `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
|
||||
|
||||
|
||||
@@ -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 (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
)
|
||||
|
||||
// RegisterAgent registers a new AI agent within the organization.
|
||||
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
|
||||
if err := c.doPost("/v1/agents/register", req, &result); err != nil {
|
||||
return nil, err
|
||||
@@ -24,22 +18,26 @@ func (c *Client) RegisterAgent(req *RegisterAgentRequest) (*RegisterAgentRespons
|
||||
// GetAgent retrieves an agent by ID.
|
||||
func (c *Client) GetAgent(agentID string) (*GetAgentResponse, error) {
|
||||
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 &result, nil
|
||||
}
|
||||
|
||||
// FreezeAgent freezes an agent, preventing it from submitting operations.
|
||||
func (c *Client) FreezeAgent(agentID, reason string) error {
|
||||
return c.doPost(fmt.Sprintf("/v1/agents/%s/freeze", agentID), &FreezeAgentRequest{
|
||||
func (c *Client) FreezeAgent(agentID, reason string) (*FreezeAgentResponse, error) {
|
||||
var result FreezeAgentResponse
|
||||
if err := c.doPost(fmt.Sprintf("/v1/agents/%s/freeze", url.PathEscape(agentID)), &FreezeAgentRequest{
|
||||
Reason: reason,
|
||||
}, nil)
|
||||
}, &result); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &result, nil
|
||||
}
|
||||
|
||||
// RevokeKey revokes a specific key for an agent.
|
||||
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,
|
||||
Reason: reason,
|
||||
}, nil)
|
||||
@@ -55,16 +53,31 @@ func (c *Client) ListAgents() (*ListAgentsResponse, error) {
|
||||
}
|
||||
|
||||
// UnfreezeAgent unfreezes a previously frozen agent.
|
||||
func (c *Client) UnfreezeAgent(agentID, reason string) error {
|
||||
return c.doPost(fmt.Sprintf("/v1/agents/%s/unfreeze", agentID), &UnfreezeAgentRequest{
|
||||
func (c *Client) UnfreezeAgent(agentID, reason string) (*UnfreezeAgentResponse, error) {
|
||||
var result UnfreezeAgentResponse
|
||||
if err := c.doPost(fmt.Sprintf("/v1/agents/%s/unfreeze", url.PathEscape(agentID)), &UnfreezeAgentRequest{
|
||||
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.
|
||||
func (c *Client) DeleteAgent(agentID string) (*DeleteAgentResponse, error) {
|
||||
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 &result, nil
|
||||
|
||||
+19
-95
@@ -1,51 +1,30 @@
|
||||
package elydora
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
import "net/http"
|
||||
|
||||
// RegisterOption is a functional option for the Register function.
|
||||
// RegisterOption configures a registration request.
|
||||
type RegisterOption func(*AuthRegisterRequest)
|
||||
|
||||
// WithDisplayName sets the display_name on a registration request.
|
||||
func WithDisplayName(name string) RegisterOption {
|
||||
return func(req *AuthRegisterRequest) {
|
||||
req.DisplayName = name
|
||||
}
|
||||
return func(req *AuthRegisterRequest) { req.DisplayName = name }
|
||||
}
|
||||
|
||||
// WithOrgName sets the org_name on a registration request.
|
||||
func WithOrgName(name string) RegisterOption {
|
||||
return func(req *AuthRegisterRequest) {
|
||||
req.OrgName = name
|
||||
}
|
||||
return func(req *AuthRegisterRequest) { req.OrgName = name }
|
||||
}
|
||||
|
||||
// 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) {
|
||||
if baseURL == "" {
|
||||
baseURL = defaultBaseURL
|
||||
}
|
||||
baseURL = strings.TrimRight(baseURL, "/")
|
||||
|
||||
req := &AuthRegisterRequest{
|
||||
Email: email,
|
||||
Password: password,
|
||||
}
|
||||
req := &AuthRegisterRequest{Email: email, Password: password}
|
||||
for _, opt := range opts {
|
||||
opt(req)
|
||||
}
|
||||
|
||||
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 &result, nil
|
||||
@@ -53,80 +32,16 @@ func Register(baseURL, email, password string, opts ...RegisterOption) (*AuthReg
|
||||
|
||||
// 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) {
|
||||
if baseURL == "" {
|
||||
baseURL = defaultBaseURL
|
||||
}
|
||||
baseURL = strings.TrimRight(baseURL, "/")
|
||||
|
||||
req := &AuthLoginRequest{
|
||||
Email: email,
|
||||
Password: password,
|
||||
}
|
||||
|
||||
req := &AuthLoginRequest{Email: email, Password: password}
|
||||
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 &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.
|
||||
func (c *Client) GetMe() (*GetMeResponse, error) {
|
||||
var result GetMeResponse
|
||||
@@ -144,3 +59,12 @@ func (c *Client) IssueApiToken(req *IssueApiTokenRequest) (*IssueApiTokenRespons
|
||||
}
|
||||
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"
|
||||
defaultTTLMs = 30000
|
||||
defaultMaxRetries = 3
|
||||
maxResponseBytes = 10 << 20
|
||||
requestTimeout = 30 * time.Second
|
||||
)
|
||||
|
||||
// Config holds the configuration for creating a new Client.
|
||||
@@ -44,135 +46,195 @@ func NewClient(cfg *Config) (*Client, error) {
|
||||
if cfg == 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
|
||||
if ttlMs <= 0 {
|
||||
ttlMs = defaultTTLMs
|
||||
}
|
||||
|
||||
maxRetries := cfg.MaxRetries
|
||||
if maxRetries <= 0 {
|
||||
maxRetries = defaultMaxRetries
|
||||
}
|
||||
|
||||
return &Client{
|
||||
orgID: cfg.OrgID,
|
||||
agentID: cfg.AgentID,
|
||||
privateKey: cfg.PrivateKey,
|
||||
baseURL: baseURL,
|
||||
baseURL: normalizeBaseURL(cfg.BaseURL),
|
||||
ttlMs: ttlMs,
|
||||
maxRetries: maxRetries,
|
||||
token: cfg.Token,
|
||||
httpClient: &http.Client{
|
||||
Timeout: 30 * time.Second,
|
||||
},
|
||||
httpClient: &http.Client{Timeout: requestTimeout},
|
||||
}, 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.
|
||||
func (c *Client) SetToken(token string) {
|
||||
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 {
|
||||
return "", err
|
||||
}
|
||||
if !os.SameFile(before, before) {
|
||||
return "", fmt.Errorf("establish %s file identity at %s", label, path)
|
||||
}
|
||||
|
||||
file, err := openFile(path)
|
||||
if err != nil {
|
||||
|
||||
@@ -65,6 +65,7 @@ func prepareClineCLIEnvironment(t *testing.T) (string, string, string, []string)
|
||||
t.Helper()
|
||||
root := t.TempDir()
|
||||
homeDir := filepath.Join(root, "home")
|
||||
preparePrivateCLITestHome(t, homeDir)
|
||||
clineDir := filepath.Join(root, "cline-home")
|
||||
workspace := filepath.Join(root, "workspace")
|
||||
if err := os.MkdirAll(workspace, 0700); err != nil {
|
||||
|
||||
@@ -14,6 +14,7 @@ func prepareDroidCLIEnvironment(t *testing.T) (string, string, []string) {
|
||||
t.Helper()
|
||||
root := t.TempDir()
|
||||
homeDir := filepath.Join(root, "home with spaces and 'quote %DROID%")
|
||||
preparePrivateCLITestHome(t, homeDir)
|
||||
workspace := filepath.Join(root, "workspace with spaces")
|
||||
if err := os.MkdirAll(filepath.Join(workspace, ".git"), 0700); err != nil {
|
||||
t.Fatalf("create Factory Droid CLI workspace: %v", err)
|
||||
|
||||
@@ -8,10 +8,10 @@ import (
|
||||
"sort"
|
||||
"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() {
|
||||
if len(os.Args) < 2 {
|
||||
@@ -57,10 +57,6 @@ Run "elydora <command> -h" for details on each command.
|
||||
`)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// install
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func cmdInstall(args []string) {
|
||||
if err := rejectLegacySecretArguments(args); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
|
||||
@@ -160,10 +156,6 @@ func cmdInstall(args []string) {
|
||||
fmt.Printf(" Guard script: %s\n", guardScriptPath)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// uninstall
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func cmdUninstall(args []string) {
|
||||
fs := flag.NewFlagSet("uninstall", flag.ExitOnError)
|
||||
agent := fs.String("agent", "", "Agent name (required)")
|
||||
@@ -209,10 +201,6 @@ func cmdUninstall(args []string) {
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// status
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func cmdStatus(args []string) {
|
||||
fs := flag.NewFlagSet("status", flag.ExitOnError)
|
||||
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)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// agents
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func cmdAgents() {
|
||||
fmt.Println("Supported agents:")
|
||||
fmt.Println()
|
||||
@@ -282,12 +266,3 @@ func sortedAgentNames() []string {
|
||||
sort.Strings(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"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/Elydora-Infrastructure/Elydora-Go-SDK/v2/cmd/elydora/plugins"
|
||||
)
|
||||
|
||||
func setMainTestHome(t *testing.T) string {
|
||||
@@ -17,9 +19,9 @@ func setMainTestHome(t *testing.T) string {
|
||||
|
||||
func TestGuardScriptPathRejectsUnsafeAgentID(t *testing.T) {
|
||||
setMainTestHome(t)
|
||||
_, err := guardScriptPathForAgent("../escape")
|
||||
_, err := plugins.ResolveAgentRuntimeDirectory("../escape")
|
||||
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
|
||||
}
|
||||
|
||||
// ManagesGuardRuntime reports that Auggie commits its provider guard together
|
||||
// with both wrappers, the audit runtime, and user settings.
|
||||
// ManagesGuardRuntime reports that the guard is committed with the audit runtime.
|
||||
func (p *AugmentPlugin) ManagesGuardRuntime() bool {
|
||||
return true
|
||||
}
|
||||
@@ -57,13 +56,14 @@ func (p *AugmentPlugin) Install(config InstallConfig) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := writeAugmentChanges(
|
||||
if err := writeManagedChanges(
|
||||
changes,
|
||||
"Install Augment Code CLI hooks",
|
||||
p.rename,
|
||||
paths.runtimeRoot,
|
||||
paths.agentDirectory,
|
||||
filepath.Dir(document.configPath),
|
||||
"Auggie configuration directory",
|
||||
); err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -99,13 +99,14 @@ func (p *AugmentPlugin) Uninstall(agentID string) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return writeAugmentChanges(
|
||||
return writeManagedChanges(
|
||||
[]*fileChange{change},
|
||||
"Uninstall Augment Code CLI hooks",
|
||||
p.rename,
|
||||
"",
|
||||
"",
|
||||
filepath.Dir(document.configPath),
|
||||
"Auggie configuration directory",
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -16,7 +16,6 @@ const (
|
||||
augmentGuardScript = "guard.js"
|
||||
augmentAuditScript = "hook.js"
|
||||
augmentHookTimeout = float64(10_000)
|
||||
augmentPOSIXApostrophe = `'"'"'`
|
||||
)
|
||||
|
||||
var (
|
||||
@@ -68,14 +67,6 @@ type augmentRuntimeContract struct {
|
||||
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(
|
||||
value any,
|
||||
event string,
|
||||
@@ -114,7 +105,7 @@ func validateAugmentHandler(
|
||||
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 {
|
||||
@@ -169,7 +160,7 @@ func validateAugmentGroup(value any, event string, groupIndex int) (augmentGroup
|
||||
}
|
||||
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) {
|
||||
@@ -248,7 +239,7 @@ func renderAugmentDocument(
|
||||
if !document.exists && len(hooks) == 0 {
|
||||
return &augmentRenderedDocument{document: document}, nil
|
||||
}
|
||||
root := cloneAugmentObject(document.root)
|
||||
root := cloneJSONObject(document.root)
|
||||
if len(hooks) == 0 {
|
||||
delete(root, "hooks")
|
||||
} else {
|
||||
@@ -281,7 +272,7 @@ func renderAugmentHooks(hooks augmentHooks) map[string]any {
|
||||
for event, groups := range hooks {
|
||||
values := make([]any, 0, len(groups))
|
||||
for _, group := range groups {
|
||||
object := cloneAugmentObject(group.object)
|
||||
object := cloneJSONObject(group.object)
|
||||
handlers := make([]any, 0, len(group.handlers))
|
||||
for _, handler := range group.handlers {
|
||||
handlers = append(handlers, handler)
|
||||
@@ -314,23 +305,8 @@ func readAugmentWindowsArgument(command string) (string, bool) {
|
||||
}
|
||||
|
||||
func readAugmentPOSIXArgument(command string) (string, bool) {
|
||||
if len(command) < 2 || command[0] != '\'' {
|
||||
return "", false
|
||||
}
|
||||
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
|
||||
value, end, ok := readPOSIXArgument(command, 0)
|
||||
return value, ok && end == len(command) && value != ""
|
||||
}
|
||||
|
||||
func parseAugmentCommand(command string) (string, bool) {
|
||||
@@ -340,36 +316,6 @@ func parseAugmentCommand(command string) (string, bool) {
|
||||
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(
|
||||
handler map[string]any,
|
||||
wrapperName, runtimeRoot string,
|
||||
@@ -385,11 +331,11 @@ func managedAugmentAgentID(
|
||||
return "", false
|
||||
}
|
||||
wrapperPath, ok := parseAugmentCommand(command)
|
||||
if !ok || !sameAugmentFileName(filepath.Base(wrapperPath), wrapperName) {
|
||||
if !ok || !sameManagedName(filepath.Base(wrapperPath), wrapperName) {
|
||||
return "", false
|
||||
}
|
||||
agentDirectory := filepath.Dir(wrapperPath)
|
||||
if !sameAugmentPath(filepath.Dir(agentDirectory), runtimeRoot) {
|
||||
if !sameManagedPath(filepath.Dir(agentDirectory), runtimeRoot) {
|
||||
return "", false
|
||||
}
|
||||
agentID := filepath.Base(agentDirectory)
|
||||
@@ -407,7 +353,7 @@ func removeManagedAugmentGroups(
|
||||
groupChanged := false
|
||||
for _, handler := range group.handlers {
|
||||
managedID, managed := managedAugmentAgentID(handler, wrapperName, runtimeRoot)
|
||||
remove := managed && (agentID == "" || sameAugmentAgentID(managedID, agentID))
|
||||
remove := managed && (agentID == "" || sameManagedAgentID(managedID, agentID))
|
||||
if remove {
|
||||
changed = true
|
||||
groupChanged = true
|
||||
|
||||
@@ -1,96 +1,42 @@
|
||||
package plugins
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
)
|
||||
import "fmt"
|
||||
|
||||
type augmentRuntimePaths struct {
|
||||
runtimeRoot string
|
||||
agentDirectory string
|
||||
configPath string
|
||||
keyPath string
|
||||
guardPath string
|
||||
auditPath string
|
||||
managedRuntimePaths
|
||||
guardWrapperPath string
|
||||
auditWrapperPath string
|
||||
}
|
||||
|
||||
func validateAugmentInstallConfig(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)
|
||||
func augmentWrapperArtifacts(agentDirectory string) []managedArtifact {
|
||||
wrappers := resolveAugmentWrapperPaths(agentDirectory)
|
||||
return []managedArtifact{
|
||||
{wrappers.guard, "Auggie guard wrapper", maxManagedSourceBytes},
|
||||
{wrappers.audit, "Auggie audit wrapper", maxManagedSourceBytes},
|
||||
}
|
||||
}
|
||||
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) {
|
||||
if err := validateAugmentInstallConfig(config); err != nil {
|
||||
if err := validateManagedInstallConfig(config, augmentAgentKey, "Augment Code CLI"); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
runtimeRoot, err := AgentRuntimeRoot()
|
||||
paths, err := resolveManagedRuntimePaths(config, augmentGuardScript, augmentAuditScript)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
agentDirectory, err := ResolveAgentRuntimeDirectory(config.AgentID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
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),
|
||||
wrappers := resolveAugmentWrapperPaths(paths.agentDirectory)
|
||||
return &augmentRuntimePaths{
|
||||
managedRuntimePaths: *paths,
|
||||
guardWrapperPath: wrappers.guard,
|
||||
auditWrapperPath: wrappers.audit,
|
||||
}
|
||||
if !filepath.IsAbs(config.GuardScriptPath) ||
|
||||
!sameAugmentPath(config.GuardScriptPath, paths.guardPath) {
|
||||
return nil, fmt.Errorf(
|
||||
"Elydora guard runtime must use the managed agent directory: %s",
|
||||
paths.guardPath,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func validateAugmentRuntimeIdentity(agentDirectory, agentID string) error {
|
||||
return validateManagedRuntimeIdentity(
|
||||
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(
|
||||
@@ -98,55 +44,25 @@ func preflightAugmentInstallation(
|
||||
document *augmentDocument,
|
||||
) (*augmentRuntimePaths, string, error) {
|
||||
if document == nil || document.configPath == "" {
|
||||
return nil, "", fmt.Errorf(
|
||||
"Augment Code CLI installation requires a user settings document",
|
||||
)
|
||||
return nil, "", fmt.Errorf("Augment Code CLI installation requires a user settings document")
|
||||
}
|
||||
paths, err := augmentAgentPaths(config)
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
if err := validateAugmentRuntimeIdentity(
|
||||
paths.agentDirectory,
|
||||
config.AgentID,
|
||||
); err != nil {
|
||||
if err := validateAugmentRuntimeIdentity(paths.agentDirectory, config.AgentID); err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
nodePath, err := resolveNodeRuntime()
|
||||
nodePath, err := resolveAbsoluteNodeRuntime("Auggie")
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
if err := requireAugmentAbsoluteNode(nodePath); err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
if err := validateAugmentMatchers(document.hooks, nodePath); err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
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(
|
||||
config InstallConfig,
|
||||
paths *augmentRuntimePaths,
|
||||
@@ -156,63 +72,27 @@ func prepareAugmentInstallationChanges(
|
||||
if paths == nil || rendered == nil {
|
||||
return nil, fmt.Errorf("prepared Auggie installation is required")
|
||||
}
|
||||
if err := validateAugmentRuntimeIdentity(
|
||||
paths.agentDirectory,
|
||||
config.AgentID,
|
||||
); err != nil {
|
||||
if err := validateAugmentRuntimeIdentity(paths.agentDirectory, config.AgentID); err != nil {
|
||||
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 {
|
||||
return nil, err
|
||||
}
|
||||
items := []struct {
|
||||
for _, item := range []struct {
|
||||
path, label string
|
||||
content []byte
|
||||
mode os.FileMode
|
||||
}{
|
||||
{
|
||||
paths.guardPath,
|
||||
"Elydora guard runtime",
|
||||
[]byte(generateGuardScript(
|
||||
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)
|
||||
{paths.guardWrapperPath, "Auggie guard wrapper", buildAugmentWrapper(nodePath, paths.guardPath)},
|
||||
{paths.auditWrapperPath, "Auggie audit wrapper", buildAugmentWrapper(nodePath, paths.auditPath)},
|
||||
} {
|
||||
change, err := prepareFileChange(item.path, item.label, item.content, 0700)
|
||||
if err != nil {
|
||||
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 {
|
||||
events := make([]string, 0, len(hooks))
|
||||
for event := range hooks {
|
||||
@@ -209,10 +172,3 @@ func resolveAugmentWrapperPaths(agentDirectory string) augmentWrapperPaths {
|
||||
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 (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"sort"
|
||||
"strings"
|
||||
)
|
||||
|
||||
func managedAugmentIDs(
|
||||
@@ -17,19 +14,10 @@ func managedAugmentIDs(
|
||||
result := map[string]string{}
|
||||
for _, group := range groups {
|
||||
for _, handler := range group.handlers {
|
||||
agentID, managed := managedAugmentAgentID(
|
||||
handler,
|
||||
wrapperName,
|
||||
runtimeRoot,
|
||||
)
|
||||
if !managed {
|
||||
continue
|
||||
agentID, managed := managedAugmentAgentID(handler, wrapperName, runtimeRoot)
|
||||
if managed {
|
||||
result[managedReferenceKey(agentID)] = agentID
|
||||
}
|
||||
key := agentID
|
||||
if runtime.GOOS == "windows" {
|
||||
key = strings.ToLower(key)
|
||||
}
|
||||
result[key] = agentID
|
||||
}
|
||||
}
|
||||
return result
|
||||
@@ -39,16 +27,8 @@ func augmentRuntimeContracts(
|
||||
hooks augmentHooks,
|
||||
runtimeRoot string,
|
||||
) []augmentRuntimeContract {
|
||||
guards := managedAugmentIDs(
|
||||
hooks["PreToolUse"],
|
||||
augmentGuardWrapperName(),
|
||||
runtimeRoot,
|
||||
)
|
||||
audits := managedAugmentIDs(
|
||||
hooks["PostToolUse"],
|
||||
augmentAuditWrapperName(),
|
||||
runtimeRoot,
|
||||
)
|
||||
guards := managedAugmentIDs(hooks["PreToolUse"], augmentGuardWrapperName(), runtimeRoot)
|
||||
audits := managedAugmentIDs(hooks["PostToolUse"], augmentAuditWrapperName(), runtimeRoot)
|
||||
keys := make([]string, 0, len(guards))
|
||||
for key := range guards {
|
||||
if _, exists := audits[key]; exists {
|
||||
@@ -72,282 +52,47 @@ func augmentRuntimeContracts(
|
||||
return contracts
|
||||
}
|
||||
|
||||
func readAugmentRuntimeConfig(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
|
||||
func augmentWrappersMatch(contract augmentRuntimeContract, nodePath string) (bool, error) {
|
||||
wrappers := resolveAugmentWrapperPaths(filepath.Dir(contract.guardPath))
|
||||
if !sameManagedPath(contract.guardWrapper, wrappers.guard) ||
|
||||
!sameManagedPath(contract.auditWrapper, wrappers.audit) {
|
||||
return false, nil
|
||||
}
|
||||
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 {
|
||||
path string
|
||||
label string
|
||||
limit int64
|
||||
path, label string
|
||||
expected []byte
|
||||
}{
|
||||
{filepath.Join(agentDirectory, "private.key"), "Elydora private key", maxProtectedSecretBytes},
|
||||
{filepath.Join(agentDirectory, augmentGuardScript), "Elydora guard runtime", maxManagedSourceBytes},
|
||||
{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},
|
||||
{contract.guardWrapper, "Auggie guard wrapper", buildAugmentWrapper(nodePath, contract.guardPath)},
|
||||
{contract.auditWrapper, "Auggie audit wrapper", buildAugmentWrapper(nodePath, contract.auditPath)},
|
||||
} {
|
||||
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"] != 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 {
|
||||
snapshot, err := readManagedFile(item.path, item.label, maxManagedSourceBytes)
|
||||
if err != nil || snapshot == nil || !bytes.Equal(snapshot.contents, item.expected) {
|
||||
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 := 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
|
||||
return true, nil
|
||||
}
|
||||
|
||||
func augmentRuntimeFilesExist(
|
||||
contracts []augmentRuntimeContract,
|
||||
runtimeRoot string,
|
||||
) (bool, error) {
|
||||
nodePath, err := resolveNodeRuntime()
|
||||
nodePath, err := resolveAbsoluteNodeRuntime("Auggie")
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
if err := requireAugmentAbsoluteNode(nodePath); err != nil {
|
||||
return false, err
|
||||
}
|
||||
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 {
|
||||
return false, err
|
||||
}
|
||||
if exists {
|
||||
return true, nil
|
||||
continue
|
||||
}
|
||||
matches, err := augmentWrappersMatch(contract, nodePath)
|
||||
if err != nil || matches {
|
||||
return matches, err
|
||||
}
|
||||
}
|
||||
return false, nil
|
||||
|
||||
@@ -46,6 +46,7 @@ func prepareAugmentFixture(
|
||||
t.Helper()
|
||||
root := t.TempDir()
|
||||
homeDir := filepath.Join(root, "home with spaces and 'quote")
|
||||
preparePrivateTransactionTestDirectory(t, homeDir)
|
||||
projectDir := filepath.Join(root, "project with spaces")
|
||||
if err := os.MkdirAll(projectDir, 0700); err != nil {
|
||||
t.Fatalf("create project directory: %v", err)
|
||||
|
||||
@@ -54,7 +54,7 @@ func TestAugmentInstallRollsBackAllSevenFiles(t *testing.T) {
|
||||
)
|
||||
failed := false
|
||||
fixture.plugin.rename = func(source, destination string) error {
|
||||
if sameAugmentPath(destination, fixture.configPath) &&
|
||||
if sameManagedPath(destination, fixture.configPath) &&
|
||||
strings.HasSuffix(source, ".tmp") &&
|
||||
!failed {
|
||||
failed = true
|
||||
@@ -87,13 +87,14 @@ func TestPreparedAugmentInstallRejectsConcurrentSettingsChange(t *testing.T) {
|
||||
if err := os.WriteFile(fixture.configPath, concurrent, 0600); err != nil {
|
||||
t.Fatalf("write concurrent settings: %v", err)
|
||||
}
|
||||
err := writeAugmentChanges(
|
||||
err := writeManagedChanges(
|
||||
changes,
|
||||
"Install Augment Code CLI hooks",
|
||||
nil,
|
||||
paths.runtimeRoot,
|
||||
paths.agentDirectory,
|
||||
filepath.Dir(document.configPath),
|
||||
"Auggie configuration directory",
|
||||
)
|
||||
if err == nil || !strings.Contains(err.Error(), "changed during installation") {
|
||||
t.Fatalf("prepared install error = %v", err)
|
||||
@@ -375,7 +376,7 @@ func TestAugmentUninstallPreservesSettingsAfterCommitFailure(t *testing.T) {
|
||||
t.Fatalf("read installed settings: %v", err)
|
||||
}
|
||||
fixture.plugin.rename = func(source, destination string) error {
|
||||
if sameAugmentPath(destination, fixture.configPath) &&
|
||||
if sameManagedPath(destination, fixture.configPath) &&
|
||||
strings.HasSuffix(source, ".tmp") {
|
||||
return errors.New("injected Auggie uninstall failure")
|
||||
}
|
||||
|
||||
@@ -10,8 +10,7 @@ type ClaudeCodePlugin struct {
|
||||
rename renameFunc
|
||||
}
|
||||
|
||||
// ManagesGuardRuntime reports that Claude Code commits its provider guard with
|
||||
// the audit runtime and user settings document.
|
||||
// ManagesGuardRuntime reports that the guard is committed with the audit runtime.
|
||||
func (p *ClaudeCodePlugin) ManagesGuardRuntime() bool {
|
||||
return true
|
||||
}
|
||||
@@ -73,13 +72,14 @@ func (p *ClaudeCodePlugin) Install(config InstallConfig) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := writeClaudeChanges(
|
||||
if err := writeManagedChanges(
|
||||
changes,
|
||||
"Install Claude Code hooks",
|
||||
p.rename,
|
||||
paths.runtimeRoot,
|
||||
paths.agentDirectory,
|
||||
filepath.Dir(document.filePath),
|
||||
"Claude Code configuration directory",
|
||||
); err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -105,13 +105,14 @@ func (p *ClaudeCodePlugin) Uninstall(agentID string) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return writeClaudeChanges(
|
||||
return writeManagedChanges(
|
||||
[]*fileChange{change},
|
||||
"Uninstall Claude Code hooks",
|
||||
p.rename,
|
||||
"",
|
||||
"",
|
||||
filepath.Dir(document.filePath),
|
||||
"Claude Code configuration directory",
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -77,19 +77,7 @@ type claudeRenderedDocument struct {
|
||||
remove bool
|
||||
}
|
||||
|
||||
type claudeRuntimeContract struct {
|
||||
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
|
||||
}
|
||||
type claudeRuntimeContract = managedRuntimeContract
|
||||
|
||||
func cloneClaudeHooks(source claudeHooks) claudeHooks {
|
||||
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) {
|
||||
@@ -332,7 +320,7 @@ func validateClaudeGroup(value any, event string, groupIndex int) (claudeGroup,
|
||||
}
|
||||
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) {
|
||||
@@ -411,7 +399,7 @@ func renderClaudeHooks(hooks claudeHooks) map[string]any {
|
||||
for event, groups := range hooks {
|
||||
values := make([]any, 0, len(groups))
|
||||
for _, group := range groups {
|
||||
object := cloneClaudeObject(group.object)
|
||||
object := cloneJSONObject(group.object)
|
||||
handlers := make([]any, 0, len(group.handlers))
|
||||
for _, handler := range group.handlers {
|
||||
handlers = append(handlers, handler)
|
||||
@@ -439,7 +427,7 @@ func renderClaudeDocument(
|
||||
document: document, changed: true, remove: true,
|
||||
}, nil
|
||||
}
|
||||
root := cloneClaudeObject(document.root)
|
||||
root := cloneJSONObject(document.root)
|
||||
if len(hooks) == 0 {
|
||||
delete(root, "hooks")
|
||||
} else {
|
||||
|
||||
@@ -1,182 +1,48 @@
|
||||
package plugins
|
||||
|
||||
import (
|
||||
"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
|
||||
}
|
||||
import "fmt"
|
||||
|
||||
func preflightClaudeInstallation(
|
||||
config InstallConfig,
|
||||
document *claudeDocument,
|
||||
) (*claudeRuntimePaths, string, error) {
|
||||
) (*managedRuntimePaths, string, error) {
|
||||
if document == nil || document.filePath == "" {
|
||||
return nil, "", fmt.Errorf(
|
||||
"Claude Code installation requires a user settings document",
|
||||
)
|
||||
return nil, "", fmt.Errorf("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 {
|
||||
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
|
||||
}
|
||||
nodePath, err := resolveNodeRuntime()
|
||||
nodePath, err := resolveAbsoluteNodeRuntime("Claude Code")
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
if err := requireClaudeAbsoluteNode(nodePath); err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
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(
|
||||
config InstallConfig,
|
||||
paths *claudeRuntimePaths,
|
||||
paths *managedRuntimePaths,
|
||||
rendered *claudeRenderedDocument,
|
||||
) ([]*fileChange, error) {
|
||||
runtimeConfig, err := buildClaudeRuntimeConfig(config)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
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(
|
||||
changes, err := managedRuntimeFileChanges(
|
||||
config,
|
||||
paths,
|
||||
claudeAgentKey,
|
||||
config.AgentID,
|
||||
"",
|
||||
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)
|
||||
generateGuardScript(claudeAgentKey, config.AgentID, "", false, ""),
|
||||
buildHookScriptWithOutput(claudeAgentKey, config.AgentID, "", false, true),
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
changes = append(changes, change)
|
||||
}
|
||||
documentChange, err := prepareRenderedClaudeChange(rendered)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
|
||||
@@ -71,38 +71,3 @@ func prepareRenderedClaudeChange(
|
||||
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
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"sort"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type claudeRuntimeReference struct {
|
||||
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
|
||||
}
|
||||
type claudeRuntimeReference = managedScriptReference
|
||||
|
||||
func claudeRuntimeReferenceForScript(
|
||||
scriptPath string,
|
||||
scriptName string,
|
||||
) (*claudeRuntimeReference, error) {
|
||||
if !filepath.IsAbs(scriptPath) ||
|
||||
!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
|
||||
return resolveManagedScript(scriptPath, scriptName)
|
||||
}
|
||||
|
||||
func claudeLegacyReference(
|
||||
@@ -95,7 +40,7 @@ func managedClaudeReference(
|
||||
handler["statusMessage"] == statusMessage {
|
||||
nodePath, nodeOK := handler["command"].(string)
|
||||
args, argsOK := handler["args"].([]any)
|
||||
if nodeOK && filepath.IsAbs(nodePath) && isClaudeNodeExecutable(nodePath) &&
|
||||
if nodeOK && filepath.IsAbs(nodePath) && isNodeExecutable(nodePath) &&
|
||||
argsOK && len(args) == 1 {
|
||||
if scriptPath, ok := args[0].(string); ok {
|
||||
return claudeRuntimeReferenceForScript(scriptPath, scriptName)
|
||||
@@ -146,7 +91,7 @@ func removeManagedClaudeGroups(
|
||||
return nil, false, err
|
||||
}
|
||||
owned := reference != nil &&
|
||||
(agentID == "" || sameClaudeAgentID(reference.agentID, agentID))
|
||||
(agentID == "" || sameManagedAgentID(reference.agentID, agentID))
|
||||
if owned {
|
||||
removed = true
|
||||
continue
|
||||
@@ -154,7 +99,7 @@ func removeManagedClaudeGroups(
|
||||
kept = append(kept, handler)
|
||||
}
|
||||
if len(kept) > 0 || !exactManagedClaudeGroup(group) {
|
||||
object := cloneClaudeObject(group.object)
|
||||
object := cloneJSONObject(group.object)
|
||||
object["hooks"] = group.object["hooks"]
|
||||
result = append(result, claudeGroup{object: object, handlers: kept})
|
||||
}
|
||||
@@ -235,13 +180,6 @@ func entirelyManagedClaudeDocument(document *claudeDocument) bool {
|
||||
return handlerCount > 0
|
||||
}
|
||||
|
||||
func claudeReferenceKey(agentID string) string {
|
||||
if runtime.GOOS == "windows" {
|
||||
return strings.ToLower(agentID)
|
||||
}
|
||||
return agentID
|
||||
}
|
||||
|
||||
func claudeReferencesForEvent(
|
||||
groups []claudeGroup,
|
||||
scriptName string,
|
||||
@@ -263,7 +201,7 @@ func claudeReferencesForEvent(
|
||||
return nil, err
|
||||
}
|
||||
if reference != nil {
|
||||
key := claudeReferenceKey(reference.agentID)
|
||||
key := managedReferenceKey(reference.agentID)
|
||||
result[key] = append(result[key], *reference)
|
||||
}
|
||||
}
|
||||
@@ -307,7 +245,7 @@ func claudeRuntimeContracts(hooks claudeHooks) ([]claudeRuntimeContract, error)
|
||||
success := successes[key]
|
||||
failure := failures[key]
|
||||
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
|
||||
}
|
||||
contracts = append(contracts, claudeRuntimeContract{
|
||||
@@ -317,10 +255,3 @@ func claudeRuntimeContracts(hooks claudeHooks) ([]claudeRuntimeContract, error)
|
||||
}
|
||||
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"},
|
||||
{"guard path", func(f *claudeFixture) {
|
||||
f.config.GuardScriptPath = filepath.Join(f.homeDir, "outside", "guard.js")
|
||||
}, "managed Elydora agent directory"},
|
||||
}, "managed agent directory"},
|
||||
{"audit path", func(f *claudeFixture) {
|
||||
f.config.HookScript = filepath.Join(f.homeDir, "outside", "hook.js")
|
||||
}, "managed Elydora agent directory"},
|
||||
}, "managed agent directory"},
|
||||
}
|
||||
for _, testCase := range tests {
|
||||
t.Run(testCase.name, func(t *testing.T) {
|
||||
|
||||
@@ -1,231 +1,5 @@
|
||||
package plugins
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"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
|
||||
func claudeRuntimeFilesExist(contracts []managedRuntimeContract) (bool, error) {
|
||||
return managedRuntimeFilesExist(contracts, claudeAgentKey, "Claude Code", nil)
|
||||
}
|
||||
@@ -59,6 +59,7 @@ func prepareClaudeFixture(t *testing.T, options claudeFixtureOptions) *claudeFix
|
||||
t.Helper()
|
||||
root := t.TempDir()
|
||||
homeDir := filepath.Join(root, "home with 'quote %CLAUDE_HOOK_EVENT%")
|
||||
preparePrivateTransactionTestDirectory(t, homeDir)
|
||||
projectDir := filepath.Join(root, "project with spaces")
|
||||
if err := os.MkdirAll(projectDir, 0700); err != nil {
|
||||
t.Fatalf("create project directory: %v", err)
|
||||
|
||||
@@ -60,7 +60,7 @@ func TestClaudeInstallRollsBackAllRuntimesAfterSettingsFailure(t *testing.T) {
|
||||
}
|
||||
before := snapshotClaudeFiles(t, paths...)
|
||||
fixture.plugin.rename = func(source, destination string) error {
|
||||
if sameClaudePath(destination, fixture.configPath) &&
|
||||
if sameManagedPath(destination, fixture.configPath) &&
|
||||
strings.HasSuffix(source, ".tmp") {
|
||||
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 {
|
||||
t.Fatalf("write concurrent settings: %v", err)
|
||||
}
|
||||
err = writeClaudeChanges(
|
||||
err = writeManagedChanges(
|
||||
changes,
|
||||
"Install Claude Code hooks",
|
||||
nil,
|
||||
paths.runtimeRoot,
|
||||
paths.agentDirectory,
|
||||
filepath.Dir(document.filePath),
|
||||
"Claude Code configuration directory",
|
||||
)
|
||||
if err == nil || !strings.Contains(err.Error(), "changed during installation") {
|
||||
t.Fatalf("prepared install error = %v", err)
|
||||
@@ -176,7 +177,7 @@ func TestClaudeUninstallPreservesSettingsAfterCommitFailure(t *testing.T) {
|
||||
t.Fatalf("read installed settings: %v", err)
|
||||
}
|
||||
fixture.plugin.rename = func(source, destination string) error {
|
||||
if sameClaudePath(destination, fixture.configPath) &&
|
||||
if sameManagedPath(destination, fixture.configPath) &&
|
||||
strings.HasSuffix(source, ".tmp") {
|
||||
return errors.New("injected Claude uninstall failure")
|
||||
}
|
||||
|
||||
@@ -7,8 +7,7 @@ type ClinePlugin struct {
|
||||
rename renameFunc
|
||||
}
|
||||
|
||||
// ManagesGuardRuntime reports that Cline commits both generated runtimes and
|
||||
// both native hook files in one transaction.
|
||||
// ManagesGuardRuntime reports that the guard is committed with the audit runtime.
|
||||
func (p *ClinePlugin) ManagesGuardRuntime() bool {
|
||||
return true
|
||||
}
|
||||
|
||||
@@ -6,7 +6,6 @@ import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
)
|
||||
|
||||
@@ -80,37 +79,6 @@ func resolveClineHookFiles() (clineHookPaths, error) {
|
||||
}, 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) {
|
||||
metadata := clineHookMetadata{
|
||||
Version: clineMetadataVersion,
|
||||
@@ -314,7 +282,7 @@ func clineContractForFiles(
|
||||
if guard.Kind != "guard" || audit.Kind != "audit" {
|
||||
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")
|
||||
}
|
||||
agentDirectory, err := ResolveAgentRuntimeDirectory(guard.AgentID)
|
||||
@@ -323,15 +291,8 @@ func clineContractForFiles(
|
||||
}
|
||||
expectedGuard := filepath.Join(agentDirectory, clineGuardScript)
|
||||
expectedAudit := filepath.Join(agentDirectory, clineAuditScript)
|
||||
guardMatches, err := sameClinePath(guard.RuntimePath, expectedGuard)
|
||||
if err != nil {
|
||||
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 {
|
||||
if !sameManagedPath(guard.RuntimePath, expectedGuard) ||
|
||||
!sameManagedPath(audit.RuntimePath, expectedAudit) {
|
||||
return nil, fmt.Errorf("Elydora Cline hook metadata references an unexpected runtime path")
|
||||
}
|
||||
return &clineRuntimeContract{
|
||||
|
||||
@@ -1,97 +1,21 @@
|
||||
package plugins
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
)
|
||||
import "fmt"
|
||||
|
||||
type clineRuntimePaths struct {
|
||||
runtimeRoot string
|
||||
agentDirectory string
|
||||
configPath string
|
||||
keyPath string
|
||||
guardPath string
|
||||
auditPath string
|
||||
managedRuntimePaths
|
||||
hooks clineHookPaths
|
||||
}
|
||||
|
||||
func validateClineInstallConfig(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 != 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 {
|
||||
func clineAgentPaths(config InstallConfig, hooks clineHookPaths) (*clineRuntimePaths, error) {
|
||||
if err := validateManagedInstallConfig(config, clineAgentKey, "Cline"); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
runtimeRoot, err := AgentRuntimeRoot()
|
||||
paths, err := resolveManagedRuntimePaths(config, clineGuardScript, clineAuditScript)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
agentDirectory, err := ResolveAgentRuntimeDirectory(config.AgentID)
|
||||
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
|
||||
return &clineRuntimePaths{managedRuntimePaths: *paths, hooks: hooks}, nil
|
||||
}
|
||||
|
||||
func preflightClineInstallation(
|
||||
@@ -102,39 +26,20 @@ func preflightClineInstallation(
|
||||
if err != nil {
|
||||
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
|
||||
}
|
||||
nodePath, err := resolveNodeRuntime()
|
||||
if err != nil {
|
||||
if _, err := resolveAbsoluteNodeRuntime("Cline"); err != nil {
|
||||
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
|
||||
}
|
||||
|
||||
func buildClineRuntimeConfig(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": 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 clineExpectedScripts(agentID string, _ map[string]any) ([]byte, []byte) {
|
||||
return []byte(generateGuardScript(clineAgentKey, agentID, "", false, "")),
|
||||
[]byte(buildHookScriptWithOutput(clineAgentKey, agentID, "", false, true))
|
||||
}
|
||||
|
||||
func prepareClineInstallationChanges(
|
||||
@@ -146,11 +51,9 @@ func prepareClineInstallationChanges(
|
||||
if paths == nil {
|
||||
return nil, fmt.Errorf("prepared Cline installation is required")
|
||||
}
|
||||
if err := validateClineRuntimeIdentity(paths.agentDirectory, config.AgentID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
runtimeConfig, err := buildClineRuntimeConfig(config)
|
||||
if err != nil {
|
||||
if err := validateManagedRuntimeIdentity(
|
||||
paths.agentDirectory, config.AgentID, clineAgentKey, "Cline",
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
guardMetadata, err := buildClineMetadata("guard", config.AgentID, paths.guardPath)
|
||||
@@ -175,24 +78,13 @@ func prepareClineInstallationChanges(
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
runtimeItems := []struct {
|
||||
path, label string
|
||||
content []byte
|
||||
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)
|
||||
guardScript, auditScript := clineExpectedScripts(config.AgentID, nil)
|
||||
changes, err := managedRuntimeFileChanges(
|
||||
config, &paths.managedRuntimePaths, clineAgentKey, string(guardScript), string(auditScript),
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
changes = append(changes, change)
|
||||
}
|
||||
for _, item := range []struct {
|
||||
file clineHookFile
|
||||
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