mirror of
https://github.com/outbackdingo/optimclaw.git
synced 2026-08-26 15:40:18 +00:00
Compare commits
4
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7c90af0c3a | ||
|
|
3dce7cf224 | ||
|
|
4a9daf704d | ||
|
|
299e8e0f11 |
Generated
-2
@@ -3482,7 +3482,6 @@ dependencies = [
|
|||||||
"wasmparser 0.220.1",
|
"wasmparser 0.220.1",
|
||||||
"wasmtime",
|
"wasmtime",
|
||||||
"wasmtime-wasi",
|
"wasmtime-wasi",
|
||||||
"webpki-roots 0.26.11",
|
|
||||||
"zbus",
|
"zbus",
|
||||||
"zip",
|
"zip",
|
||||||
]
|
]
|
||||||
@@ -6992,7 +6991,6 @@ dependencies = [
|
|||||||
"futures-util",
|
"futures-util",
|
||||||
"http 1.4.0",
|
"http 1.4.0",
|
||||||
"http-body 1.0.1",
|
"http-body 1.0.1",
|
||||||
"http-body-util",
|
|
||||||
"iri-string",
|
"iri-string",
|
||||||
"pin-project-lite",
|
"pin-project-lite",
|
||||||
"tower 0.5.3",
|
"tower 0.5.3",
|
||||||
|
|||||||
+1
-4
@@ -57,7 +57,6 @@ refinery = { version = "0.8", features = ["tokio-postgres"], optional = true }
|
|||||||
tokio-postgres-rustls = { version = "0.13", optional = true }
|
tokio-postgres-rustls = { version = "0.13", optional = true }
|
||||||
rustls = { version = "0.23", optional = true, default-features = false }
|
rustls = { version = "0.23", optional = true, default-features = false }
|
||||||
rustls-native-certs = { version = "0.8", optional = true }
|
rustls-native-certs = { version = "0.8", optional = true }
|
||||||
webpki-roots = { version = "0.26", optional = true }
|
|
||||||
|
|
||||||
# Database - libSQL/Turso (optional embedded database)
|
# Database - libSQL/Turso (optional embedded database)
|
||||||
libsql = { version = "0.6", optional = true, default-features = false, features = ["core", "replication", "remote", "tls"] }
|
libsql = { version = "0.6", optional = true, default-features = false, features = ["core", "replication", "remote", "tls"] }
|
||||||
@@ -96,7 +95,7 @@ termimad = "0.34"
|
|||||||
# Channel integrations
|
# Channel integrations
|
||||||
axum = { version = "0.8", features = ["ws"] }
|
axum = { version = "0.8", features = ["ws"] }
|
||||||
tower = "0.5"
|
tower = "0.5"
|
||||||
tower-http = { version = "0.6", features = ["trace", "cors", "set-header", "catch-panic"] }
|
tower-http = { version = "0.6", features = ["trace", "cors", "set-header"] }
|
||||||
|
|
||||||
# Cron scheduling for routines
|
# Cron scheduling for routines
|
||||||
cron = "0.13"
|
cron = "0.13"
|
||||||
@@ -220,7 +219,6 @@ postgres = [
|
|||||||
"dep:tokio-postgres-rustls",
|
"dep:tokio-postgres-rustls",
|
||||||
"dep:rustls",
|
"dep:rustls",
|
||||||
"dep:rustls-native-certs",
|
"dep:rustls-native-certs",
|
||||||
"dep:webpki-roots",
|
|
||||||
"dep:postgres-types",
|
"dep:postgres-types",
|
||||||
"dep:refinery",
|
"dep:refinery",
|
||||||
"dep:pgvector",
|
"dep:pgvector",
|
||||||
@@ -232,7 +230,6 @@ libsql = ["dep:libsql"]
|
|||||||
integration = []
|
integration = []
|
||||||
html-to-markdown = ["dep:html-to-markdown-rs", "dep:readabilityrs"]
|
html-to-markdown = ["dep:html-to-markdown-rs", "dep:readabilityrs"]
|
||||||
bedrock = ["dep:aws-config", "dep:aws-sdk-bedrockruntime", "dep:aws-smithy-types"]
|
bedrock = ["dep:aws-config", "dep:aws-sdk-bedrockruntime", "dep:aws-smithy-types"]
|
||||||
demo = []
|
|
||||||
import = ["dep:json5", "libsql"]
|
import = ["dep:json5", "libsql"]
|
||||||
|
|
||||||
[[test]]
|
[[test]]
|
||||||
|
|||||||
+9
-39
@@ -1,78 +1,49 @@
|
|||||||
# Multi-stage Dockerfile for the IronClaw agent (cloud deployment).
|
# Multi-stage Dockerfile for the IronClaw agent (cloud deployment).
|
||||||
#
|
#
|
||||||
# Uses cargo-chef for dependency caching — only rebuilds deps when
|
|
||||||
# Cargo.toml/Cargo.lock change, not on every source edit.
|
|
||||||
#
|
|
||||||
# Build:
|
# Build:
|
||||||
# docker build --platform linux/amd64 -t ironclaw:latest .
|
# docker build --platform linux/amd64 -t ironclaw:latest .
|
||||||
#
|
#
|
||||||
# Run:
|
# Run:
|
||||||
# docker run --env-file .env -p 3000:3000 ironclaw:latest
|
# docker run --env-file .env -p 3000:3000 ironclaw:latest
|
||||||
|
|
||||||
# Stage 1: Install cargo-chef
|
# Stage 1: Build
|
||||||
FROM rust:1.92-slim-bookworm AS chef
|
FROM rust:1.92-slim-bookworm AS builder
|
||||||
|
|
||||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||||
pkg-config libssl-dev cmake gcc g++ \
|
pkg-config libssl-dev cmake gcc g++ \
|
||||||
&& rm -rf /var/lib/apt/lists/* \
|
&& rm -rf /var/lib/apt/lists/* \
|
||||||
&& rustup target add wasm32-wasip2 \
|
&& rustup target add wasm32-wasip2 \
|
||||||
&& cargo install cargo-chef wasm-tools
|
&& cargo install wasm-tools
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
# Stage 2: Generate the dependency recipe (changes only when Cargo.toml/lock change)
|
# Copy manifests first for layer caching
|
||||||
FROM chef AS planner
|
|
||||||
|
|
||||||
COPY Cargo.toml Cargo.lock ./
|
COPY Cargo.toml Cargo.lock ./
|
||||||
COPY crates/ crates/
|
COPY crates/ crates/
|
||||||
|
|
||||||
|
# Copy source, build script, tests, and supporting directories
|
||||||
COPY build.rs build.rs
|
COPY build.rs build.rs
|
||||||
COPY src/ src/
|
COPY src/ src/
|
||||||
COPY tests/ tests/
|
COPY tests/ tests/
|
||||||
COPY benches/ benches/
|
|
||||||
COPY migrations/ migrations/
|
COPY migrations/ migrations/
|
||||||
COPY registry/ registry/
|
COPY registry/ registry/
|
||||||
COPY channels-src/ channels-src/
|
COPY channels-src/ channels-src/
|
||||||
COPY wit/ wit/
|
COPY wit/ wit/
|
||||||
COPY providers.json providers.json
|
COPY providers.json providers.json
|
||||||
|
# [[bench]] entries in Cargo.toml require bench sources to exist for cargo to parse the manifest
|
||||||
RUN cargo chef prepare --recipe-path recipe.json
|
|
||||||
|
|
||||||
# Stage 3: Build dependencies (cached unless Cargo.toml/lock change)
|
|
||||||
FROM chef AS deps
|
|
||||||
|
|
||||||
COPY --from=planner /app/recipe.json recipe.json
|
|
||||||
RUN cargo chef cook --release --recipe-path recipe.json
|
|
||||||
|
|
||||||
# Stage 4: Build the actual binary (only recompiles ironclaw source)
|
|
||||||
FROM deps AS builder
|
|
||||||
|
|
||||||
COPY Cargo.toml Cargo.lock ./
|
|
||||||
COPY crates/ crates/
|
|
||||||
COPY build.rs build.rs
|
|
||||||
COPY src/ src/
|
|
||||||
COPY tests/ tests/
|
|
||||||
COPY benches/ benches/
|
COPY benches/ benches/
|
||||||
COPY migrations/ migrations/
|
|
||||||
COPY registry/ registry/
|
|
||||||
COPY channels-src/ channels-src/
|
|
||||||
COPY wit/ wit/
|
|
||||||
COPY providers.json providers.json
|
|
||||||
|
|
||||||
COPY skills/ skills/
|
RUN cargo build --release --bin ironclaw
|
||||||
|
|
||||||
RUN cargo build --release --features demo --bin ironclaw
|
# Stage 2: Runtime
|
||||||
|
|
||||||
# Stage 5: Runtime
|
|
||||||
FROM debian:bookworm-slim
|
FROM debian:bookworm-slim
|
||||||
|
|
||||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||||
ca-certificates libssl3 \
|
ca-certificates libssl3 \
|
||||||
&& update-ca-certificates \
|
|
||||||
&& rm -rf /var/lib/apt/lists/*
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
COPY --from=builder /app/target/release/ironclaw /usr/local/bin/ironclaw
|
COPY --from=builder /app/target/release/ironclaw /usr/local/bin/ironclaw
|
||||||
COPY --from=builder /app/migrations /app/migrations
|
COPY --from=builder /app/migrations /app/migrations
|
||||||
COPY --from=builder /app/skills /app/skills
|
|
||||||
|
|
||||||
# Non-root user
|
# Non-root user
|
||||||
RUN useradd -m -u 1000 -s /bin/bash ironclaw
|
RUN useradd -m -u 1000 -s /bin/bash ironclaw
|
||||||
@@ -81,6 +52,5 @@ USER ironclaw
|
|||||||
EXPOSE 3000
|
EXPOSE 3000
|
||||||
|
|
||||||
ENV RUST_LOG=ironclaw=info
|
ENV RUST_LOG=ironclaw=info
|
||||||
ENV SKILLS_DIR=/app/skills
|
|
||||||
|
|
||||||
ENTRYPOINT ["ironclaw"]
|
ENTRYPOINT ["ironclaw"]
|
||||||
|
|||||||
@@ -1,571 +0,0 @@
|
|||||||
# User Management API
|
|
||||||
|
|
||||||
DB-backed user management for multi-tenant IronClaw deployments. Covers admin user CRUD, per-user secrets provisioning, self-service profile, API token management, and usage reporting.
|
|
||||||
|
|
||||||
## Authentication
|
|
||||||
|
|
||||||
All endpoints require `Authorization: Bearer <token>`. Tokens are either:
|
|
||||||
- **Env-var tokens** — configured via `GATEWAY_AUTH_TOKEN` (single-user) at startup
|
|
||||||
- **DB-backed tokens** — created via `POST /api/tokens` or `POST /api/admin/users`
|
|
||||||
|
|
||||||
DB tokens are SHA-256 hashed at rest; plaintext is returned exactly once at creation time.
|
|
||||||
|
|
||||||
Auth is cached in a bounded LRU (1024 entries, 60s TTL). Suspending a user or revoking a token may take up to 60s to take effect.
|
|
||||||
|
|
||||||
## Roles
|
|
||||||
|
|
||||||
| Role | Scope |
|
|
||||||
|------|-------|
|
|
||||||
| `admin` | Full access to all endpoints |
|
|
||||||
| `member` | Self-service profile + own token management only |
|
|
||||||
|
|
||||||
Endpoints marked **Admin** return `403 Forbidden` for `member` role.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Admin: Users
|
|
||||||
|
|
||||||
### POST /api/admin/users
|
|
||||||
|
|
||||||
Create a new user. Returns the user record and a one-time plaintext API token.
|
|
||||||
|
|
||||||
**Auth:** Admin
|
|
||||||
|
|
||||||
**Request body:**
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"display_name": "Alice Smith",
|
|
||||||
"email": "[email protected]",
|
|
||||||
"role": "member"
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
| Field | Type | Required | Default | Notes |
|
|
||||||
|-------|------|----------|---------|-------|
|
|
||||||
| `display_name` | string | yes | | |
|
|
||||||
| `email` | string | no | `null` | Must be unique if provided |
|
|
||||||
| `role` | string | no | `"member"` | `"admin"` or `"member"` |
|
|
||||||
|
|
||||||
**Response:** `200 OK`
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"id": "550e8400-e29b-41d4-a716-446655440000",
|
|
||||||
"email": "[email protected]",
|
|
||||||
"display_name": "Alice Smith",
|
|
||||||
"status": "active",
|
|
||||||
"role": "member",
|
|
||||||
"token": "a1b2c3d4e5f6...64-char hex...",
|
|
||||||
"created_at": "2026-03-25T12:00:00+00:00",
|
|
||||||
"created_by": "admin-user-id"
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
The `token` field is the plaintext API token. It is shown **only once** — store it securely.
|
|
||||||
|
|
||||||
**Errors:** `400` (missing display_name, invalid role), `403` (not admin), `503` (no database)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### GET /api/admin/users
|
|
||||||
|
|
||||||
List all users.
|
|
||||||
|
|
||||||
**Auth:** Admin
|
|
||||||
|
|
||||||
**Response:** `200 OK`
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"users": [
|
|
||||||
{
|
|
||||||
"id": "550e8400-...",
|
|
||||||
"email": "[email protected]",
|
|
||||||
"display_name": "Alice Smith",
|
|
||||||
"status": "active",
|
|
||||||
"role": "member",
|
|
||||||
"created_at": "2026-03-25T12:00:00+00:00",
|
|
||||||
"updated_at": "2026-03-25T12:00:00+00:00",
|
|
||||||
"last_login_at": "2026-03-25T14:30:00+00:00",
|
|
||||||
"created_by": "admin-user-id"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### GET /api/admin/users/{id}
|
|
||||||
|
|
||||||
Get a single user by ID.
|
|
||||||
|
|
||||||
**Auth:** Admin
|
|
||||||
|
|
||||||
**Response:** `200 OK`
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"id": "550e8400-...",
|
|
||||||
"email": "[email protected]",
|
|
||||||
"display_name": "Alice Smith",
|
|
||||||
"status": "active",
|
|
||||||
"role": "member",
|
|
||||||
"created_at": "2026-03-25T12:00:00+00:00",
|
|
||||||
"updated_at": "2026-03-25T12:00:00+00:00",
|
|
||||||
"last_login_at": "2026-03-25T14:30:00+00:00",
|
|
||||||
"created_by": "admin-user-id",
|
|
||||||
"metadata": {}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**Errors:** `404` (user not found), `403` (not admin)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### PATCH /api/admin/users/{id}
|
|
||||||
|
|
||||||
Update a user's display name and/or metadata. Omitted fields are left unchanged.
|
|
||||||
|
|
||||||
**Auth:** Admin
|
|
||||||
|
|
||||||
**Request body:**
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"display_name": "Alice Johnson",
|
|
||||||
"metadata": {"department": "engineering"}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
| Field | Type | Required | Notes |
|
|
||||||
|-------|------|----------|-------|
|
|
||||||
| `display_name` | string | no | |
|
|
||||||
| `metadata` | object | no | Replaces entire metadata object (merge patch) |
|
|
||||||
|
|
||||||
**Response:** `200 OK` — returns the full updated user record (same shape as GET detail, without `last_login_at`/`created_by`).
|
|
||||||
|
|
||||||
**Errors:** `404` (user not found), `403` (not admin)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### POST /api/admin/users/{id}/suspend
|
|
||||||
|
|
||||||
Suspend a user. Suspended users cannot authenticate (DB auth checks user status).
|
|
||||||
|
|
||||||
**Auth:** Admin
|
|
||||||
|
|
||||||
**Response:** `200 OK`
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"id": "550e8400-...",
|
|
||||||
"status": "suspended"
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**Errors:** `404` (user not found), `403` (not admin)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### POST /api/admin/users/{id}/activate
|
|
||||||
|
|
||||||
Re-activate a suspended user.
|
|
||||||
|
|
||||||
**Auth:** Admin
|
|
||||||
|
|
||||||
**Response:** `200 OK`
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"id": "550e8400-...",
|
|
||||||
"status": "active"
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**Errors:** `404` (user not found), `403` (not admin)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### DELETE /api/admin/users/{id}
|
|
||||||
|
|
||||||
Permanently delete a user and all associated data (tokens, jobs, conversations, memory, routines, settings, secrets).
|
|
||||||
|
|
||||||
**Auth:** Admin
|
|
||||||
|
|
||||||
**Response:** `200 OK`
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"id": "550e8400-...",
|
|
||||||
"deleted": true
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**Errors:** `404` (user not found), `403` (not admin)
|
|
||||||
|
|
||||||
**Cascade:** Deletes from `api_tokens`, `agent_jobs`, `conversations`, `memory_documents`, `routines`, `secrets`, `settings`, `wasm_tools`, and related tables. On PostgreSQL this uses FK cascades; on libSQL it uses explicit deletes.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Admin: Per-User Secrets
|
|
||||||
|
|
||||||
Provision secrets on behalf of individual users. The primary use case is an application backend (acting as admin) that configures per-user credentials so each user's IronClaw agent can call back to external services.
|
|
||||||
|
|
||||||
Secrets are encrypted at rest with AES-256-GCM using a per-secret HKDF-derived key. Plaintext values are **never returned** by any endpoint — they can only be used by the agent's tool system at runtime.
|
|
||||||
|
|
||||||
### PUT /api/admin/users/{user_id}/secrets/{name}
|
|
||||||
|
|
||||||
Create or update a secret for the specified user. If a secret with the same name already exists, it is overwritten.
|
|
||||||
|
|
||||||
**Auth:** Admin
|
|
||||||
|
|
||||||
**Path parameters:**
|
|
||||||
|
|
||||||
| Param | Type | Notes |
|
|
||||||
|-------|------|-------|
|
|
||||||
| `user_id` | string | The user's ID |
|
|
||||||
| `name` | string | Secret name (normalized to lowercase) |
|
|
||||||
|
|
||||||
**Request body:**
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"value": "sk-live-abc123...",
|
|
||||||
"provider": "my-app-backend",
|
|
||||||
"expires_in_days": 90
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
| Field | Type | Required | Notes |
|
|
||||||
|-------|------|----------|-------|
|
|
||||||
| `value` | string | yes | The secret value (encrypted at rest, never returned) |
|
|
||||||
| `provider` | string | no | Tag for grouping (e.g. `"stripe"`, `"my-app"`) |
|
|
||||||
| `expires_in_days` | integer | no | Auto-expire after N days; `null` = never |
|
|
||||||
|
|
||||||
**Response:** `200 OK`
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"user_id": "550e8400-...",
|
|
||||||
"name": "my_app_callback_token",
|
|
||||||
"status": "created"
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**Errors:** `400` (missing value), `403` (not admin), `503` (secrets store not available)
|
|
||||||
|
|
||||||
**Example — application backend provisioning a callback token:**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Admin creates a user
|
|
||||||
curl -X POST https://ironclaw.example.com/api/admin/users \
|
|
||||||
-H "Authorization: Bearer $ADMIN_TOKEN" \
|
|
||||||
-d '{"display_name": "Alice", "role": "member"}'
|
|
||||||
# Response includes: {"id": "alice-uuid", "token": "alice-bearer-token", ...}
|
|
||||||
|
|
||||||
# Admin provisions a per-user callback secret
|
|
||||||
curl -X PUT https://ironclaw.example.com/api/admin/users/alice-uuid/secrets/app_callback_token \
|
|
||||||
-H "Authorization: Bearer $ADMIN_TOKEN" \
|
|
||||||
-d '{"value": "per-user-jwt-for-alice", "provider": "my-app"}'
|
|
||||||
|
|
||||||
# Now Alice's IronClaw agent can use the "app_callback_token" secret
|
|
||||||
# when calling tools that need to authenticate back to the app backend.
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### GET /api/admin/users/{user_id}/secrets
|
|
||||||
|
|
||||||
List a user's secrets. Returns names and providers only — **never values or hashes**.
|
|
||||||
|
|
||||||
**Auth:** Admin
|
|
||||||
|
|
||||||
**Response:** `200 OK`
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"user_id": "550e8400-...",
|
|
||||||
"secrets": [
|
|
||||||
{"name": "app_callback_token", "provider": "my-app"},
|
|
||||||
{"name": "openai_api_key", "provider": "openai"}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### DELETE /api/admin/users/{user_id}/secrets/{name}
|
|
||||||
|
|
||||||
Delete a specific secret for a user.
|
|
||||||
|
|
||||||
**Auth:** Admin
|
|
||||||
|
|
||||||
**Response:** `200 OK`
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"user_id": "550e8400-...",
|
|
||||||
"name": "app_callback_token",
|
|
||||||
"deleted": true
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**Errors:** `404` (secret not found), `403` (not admin), `503` (secrets store not available)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Admin: Usage
|
|
||||||
|
|
||||||
### GET /api/admin/usage
|
|
||||||
|
|
||||||
Per-user LLM usage statistics aggregated from `llm_calls` via `agent_jobs.user_id`.
|
|
||||||
|
|
||||||
**Auth:** Admin
|
|
||||||
|
|
||||||
**Query parameters:**
|
|
||||||
|
|
||||||
| Param | Type | Default | Notes |
|
|
||||||
|-------|------|---------|-------|
|
|
||||||
| `user_id` | string | all users | Filter to a single user |
|
|
||||||
| `period` | string | `"day"` | `"day"` (24h), `"week"` (7d), or `"month"` (30d) |
|
|
||||||
|
|
||||||
**Response:** `200 OK`
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"period": "week",
|
|
||||||
"since": "2026-03-18T12:00:00+00:00",
|
|
||||||
"usage": [
|
|
||||||
{
|
|
||||||
"user_id": "alice-id",
|
|
||||||
"model": "claude-sonnet-4-5-20250514",
|
|
||||||
"call_count": 42,
|
|
||||||
"input_tokens": 150000,
|
|
||||||
"output_tokens": 30000,
|
|
||||||
"total_cost": "1.23"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Self-Service: Profile
|
|
||||||
|
|
||||||
### GET /api/profile
|
|
||||||
|
|
||||||
Get the authenticated user's own profile.
|
|
||||||
|
|
||||||
**Auth:** Any authenticated user
|
|
||||||
|
|
||||||
**Response:** `200 OK`
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"id": "550e8400-...",
|
|
||||||
"email": "[email protected]",
|
|
||||||
"display_name": "Alice Smith",
|
|
||||||
"status": "active",
|
|
||||||
"role": "member",
|
|
||||||
"created_at": "2026-03-25T12:00:00+00:00",
|
|
||||||
"last_login_at": "2026-03-25T14:30:00+00:00"
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### PATCH /api/profile
|
|
||||||
|
|
||||||
Update the authenticated user's own display name and/or metadata.
|
|
||||||
|
|
||||||
**Auth:** Any authenticated user
|
|
||||||
|
|
||||||
**Request body:**
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"display_name": "Alice Johnson",
|
|
||||||
"metadata": {"theme": "dark"}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**Response:** `200 OK`
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"id": "550e8400-...",
|
|
||||||
"display_name": "Alice Johnson",
|
|
||||||
"updated": true
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Self-Service: Tokens
|
|
||||||
|
|
||||||
### POST /api/tokens
|
|
||||||
|
|
||||||
Create a new API token for the authenticated user. Admins can optionally create tokens for other users by including `user_id`.
|
|
||||||
|
|
||||||
**Auth:** Any authenticated user
|
|
||||||
|
|
||||||
**Request body:**
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"name": "CI pipeline",
|
|
||||||
"expires_in_days": 90,
|
|
||||||
"user_id": "other-user-id"
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
| Field | Type | Required | Notes |
|
|
||||||
|-------|------|----------|-------|
|
|
||||||
| `name` | string | yes | Human-readable label |
|
|
||||||
| `expires_in_days` | integer | no | `null` = never expires |
|
|
||||||
| `user_id` | string | no | Admin-only; create token for another user |
|
|
||||||
|
|
||||||
**Response:** `200 OK`
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"token": "a1b2c3d4...64-char hex...",
|
|
||||||
"id": "token-uuid",
|
|
||||||
"name": "CI pipeline",
|
|
||||||
"token_prefix": "a1b2c3d4",
|
|
||||||
"expires_at": "2026-06-23T12:00:00+00:00",
|
|
||||||
"created_at": "2026-03-25T12:00:00+00:00"
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
The `token` field is shown **only once**.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### GET /api/tokens
|
|
||||||
|
|
||||||
List the authenticated user's tokens. Token hashes are never returned.
|
|
||||||
|
|
||||||
**Auth:** Any authenticated user
|
|
||||||
|
|
||||||
**Response:** `200 OK`
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"tokens": [
|
|
||||||
{
|
|
||||||
"id": "token-uuid",
|
|
||||||
"name": "CI pipeline",
|
|
||||||
"token_prefix": "a1b2c3d4",
|
|
||||||
"expires_at": "2026-06-23T12:00:00+00:00",
|
|
||||||
"last_used_at": "2026-03-25T14:00:00+00:00",
|
|
||||||
"created_at": "2026-03-25T12:00:00+00:00",
|
|
||||||
"revoked_at": null
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### DELETE /api/tokens/{id}
|
|
||||||
|
|
||||||
Revoke one of the authenticated user's tokens. Users can only revoke their own tokens.
|
|
||||||
|
|
||||||
**Auth:** Any authenticated user
|
|
||||||
|
|
||||||
**Path:** `id` — UUID of the token to revoke
|
|
||||||
|
|
||||||
**Response:** `200 OK`
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"status": "revoked",
|
|
||||||
"id": "token-uuid"
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**Errors:** `400` (invalid UUID), `404` (token not found or belongs to another user)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Error Format
|
|
||||||
|
|
||||||
All error responses return a plain text body with the error message and the corresponding HTTP status code:
|
|
||||||
|
|
||||||
| Code | Meaning |
|
|
||||||
|------|---------|
|
|
||||||
| `400` | Bad request (missing fields, invalid input) |
|
|
||||||
| `401` | Missing or invalid bearer token |
|
|
||||||
| `403` | Authenticated but insufficient role (member accessing admin endpoint) |
|
|
||||||
| `404` | Resource not found |
|
|
||||||
| `503` | Database or secrets store not available |
|
|
||||||
| `500` | Internal server error |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Security Model
|
|
||||||
|
|
||||||
### Secrets Encryption
|
|
||||||
|
|
||||||
- **Algorithm:** AES-256-GCM with per-secret HKDF-SHA256 derived keys
|
|
||||||
- **Master key:** 32+ bytes, resolved from `SECRETS_MASTER_KEY` env var or OS keychain
|
|
||||||
- **Storage format:** `nonce (12B) || ciphertext || tag (16B)` in `encrypted_value` column
|
|
||||||
- **Per-secret salt:** 32 random bytes stored alongside the ciphertext
|
|
||||||
- **Zero-exposure:** Plaintext never appears in logs, debug output, API responses, or LLM conversations
|
|
||||||
|
|
||||||
### Auth Cache
|
|
||||||
|
|
||||||
- Bounded LRU cache (1024 entries max)
|
|
||||||
- 60-second TTL per entry
|
|
||||||
- Suspending a user or revoking a token takes up to 60s to propagate
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Database Schema
|
|
||||||
|
|
||||||
### users
|
|
||||||
|
|
||||||
| Column | Type (PG / libSQL) | Notes |
|
|
||||||
|--------|--------------------|-------|
|
|
||||||
| `id` | `UUID` / `TEXT` | Primary key, UUID v4 |
|
|
||||||
| `email` | `TEXT UNIQUE` | Nullable |
|
|
||||||
| `display_name` | `TEXT NOT NULL` | |
|
|
||||||
| `status` | `TEXT NOT NULL` | `"active"` or `"suspended"` |
|
|
||||||
| `role` | `TEXT NOT NULL` | `"admin"` or `"member"` |
|
|
||||||
| `created_at` | `TIMESTAMPTZ` / `TEXT` | |
|
|
||||||
| `updated_at` | `TIMESTAMPTZ` / `TEXT` | |
|
|
||||||
| `last_login_at` | `TIMESTAMPTZ` / `TEXT` | Nullable |
|
|
||||||
| `created_by` | `TEXT` | Nullable, references `users.id` |
|
|
||||||
| `metadata` | `JSONB` / `TEXT` | Default `{}` |
|
|
||||||
|
|
||||||
### api_tokens
|
|
||||||
|
|
||||||
| Column | Type (PG / libSQL) | Notes |
|
|
||||||
|--------|--------------------|-------|
|
|
||||||
| `id` | `UUID` / `TEXT` | Primary key |
|
|
||||||
| `user_id` | `TEXT NOT NULL` | FK to `users.id` (PG cascades; libSQL explicit cleanup) |
|
|
||||||
| `token_hash` | `BYTEA` / `BLOB` | SHA-256 of hex-encoded plaintext |
|
|
||||||
| `token_prefix` | `TEXT NOT NULL` | First 8 chars for identification |
|
|
||||||
| `name` | `TEXT NOT NULL` | Human-readable label |
|
|
||||||
| `expires_at` | `TIMESTAMPTZ` / `TEXT` | Nullable |
|
|
||||||
| `last_used_at` | `TIMESTAMPTZ` / `TEXT` | Nullable |
|
|
||||||
| `created_at` | `TIMESTAMPTZ` / `TEXT` | |
|
|
||||||
| `revoked_at` | `TIMESTAMPTZ` / `TEXT` | Nullable; set on revocation |
|
|
||||||
|
|
||||||
### secrets
|
|
||||||
|
|
||||||
| Column | Type (PG / libSQL) | Notes |
|
|
||||||
|--------|--------------------|-------|
|
|
||||||
| `id` | `UUID` / `TEXT` | Primary key |
|
|
||||||
| `user_id` | `TEXT NOT NULL` | Scoped to user |
|
|
||||||
| `name` | `TEXT NOT NULL` | Unique per user (lowercase normalized) |
|
|
||||||
| `encrypted_value` | `BYTEA` / `BLOB` | AES-256-GCM (nonce + ciphertext + tag) |
|
|
||||||
| `key_salt` | `BYTEA` / `BLOB` | Per-secret HKDF salt |
|
|
||||||
| `provider` | `TEXT` | Optional grouping tag |
|
|
||||||
| `expires_at` | `TIMESTAMPTZ` / `TEXT` | Nullable |
|
|
||||||
| `last_used_at` | `TIMESTAMPTZ` / `TEXT` | Audit: last injection time |
|
|
||||||
| `usage_count` | `BIGINT` / `INTEGER` | Audit: total injections |
|
|
||||||
| `created_at` | `TIMESTAMPTZ` / `TEXT` | |
|
|
||||||
| `updated_at` | `TIMESTAMPTZ` / `TEXT` | |
|
|
||||||
@@ -1,31 +0,0 @@
|
|||||||
-- User management tables for multi-tenant deployments.
|
|
||||||
--
|
|
||||||
-- Replaces the static GATEWAY_USER_TOKENS env var with DB-backed
|
|
||||||
-- user registration, API token management, and invitation flow.
|
|
||||||
|
|
||||||
CREATE TABLE users (
|
|
||||||
id TEXT PRIMARY KEY, -- matches existing user_id pattern (string, not UUID)
|
|
||||||
email TEXT UNIQUE, -- nullable for token-only users
|
|
||||||
display_name TEXT NOT NULL,
|
|
||||||
status TEXT NOT NULL DEFAULT 'active', -- active | suspended | deactivated
|
|
||||||
role TEXT NOT NULL DEFAULT 'member', -- admin | member
|
|
||||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
|
||||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
|
||||||
last_login_at TIMESTAMPTZ,
|
|
||||||
created_by TEXT REFERENCES users(id), -- who invited this user (nullable for bootstrap)
|
|
||||||
metadata JSONB NOT NULL DEFAULT '{}' -- extensible profile data
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE TABLE api_tokens (
|
|
||||||
id UUID PRIMARY KEY,
|
|
||||||
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
|
||||||
token_hash BYTEA NOT NULL, -- SHA-256 hash (never store plaintext)
|
|
||||||
token_prefix TEXT NOT NULL, -- first 8 hex chars for display
|
|
||||||
name TEXT NOT NULL, -- human label ("my-laptop", "ci-bot")
|
|
||||||
expires_at TIMESTAMPTZ, -- nullable = never expires
|
|
||||||
last_used_at TIMESTAMPTZ,
|
|
||||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
|
||||||
revoked_at TIMESTAMPTZ -- soft-revoke: set this instead of deleting
|
|
||||||
);
|
|
||||||
CREATE INDEX idx_api_tokens_user ON api_tokens(user_id);
|
|
||||||
CREATE INDEX idx_api_tokens_hash ON api_tokens(token_hash);
|
|
||||||
@@ -1,94 +0,0 @@
|
|||||||
---
|
|
||||||
name: abound-remittance
|
|
||||||
version: 0.1.0
|
|
||||||
description: Smart remittance assistant for Abound — helps users send money to India with intelligent forex timing and transfer management.
|
|
||||||
activation:
|
|
||||||
keywords:
|
|
||||||
- send money
|
|
||||||
- transfer
|
|
||||||
- remittance
|
|
||||||
- exchange rate
|
|
||||||
- forex
|
|
||||||
- INR
|
|
||||||
- India
|
|
||||||
- wire
|
|
||||||
- schedule trade
|
|
||||||
- trade tomorrow
|
|
||||||
- convert currency
|
|
||||||
- send dollars
|
|
||||||
- rupees
|
|
||||||
- beneficiary
|
|
||||||
- funding source
|
|
||||||
- payment
|
|
||||||
- how much
|
|
||||||
- rate today
|
|
||||||
- best time
|
|
||||||
- family maintenance
|
|
||||||
patterns:
|
|
||||||
- "send \\$?\\d+"
|
|
||||||
- "schedule.*(trade|transfer|send|wire)"
|
|
||||||
- "how much.*(INR|rupees|India)"
|
|
||||||
- "best time to (send|transfer|convert)"
|
|
||||||
- "(rate|forex).*(good|bad|high|low|today|now)"
|
|
||||||
- "transfer.*tomorrow|tomorrow.*transfer"
|
|
||||||
tags:
|
|
||||||
- fintech
|
|
||||||
- remittance
|
|
||||||
- forex
|
|
||||||
max_context_tokens: 2500
|
|
||||||
---
|
|
||||||
|
|
||||||
# Abound Remittance Assistant
|
|
||||||
|
|
||||||
You are a smart remittance assistant for Abound, helping users send money from USD to INR (India) with intelligent timing advice.
|
|
||||||
|
|
||||||
## Available Tools
|
|
||||||
|
|
||||||
You have these Abound-specific tools:
|
|
||||||
|
|
||||||
- **abound_get_account_info** — Get the user's account: limits, recipients, funding sources, payment reasons
|
|
||||||
- **abound_get_exchange_rate** — Get current USD/INR exchange rate (current + effective after fees)
|
|
||||||
- **abound_get_forex_score** — Get a 0-100 forex timing score with a signal (convert_now / split_transfer / wait)
|
|
||||||
- **abound_send_wire** — Execute a wire transfer (requires: funding_source_id, beneficiary_ref_id, amount, payment_reason_key)
|
|
||||||
- **abound_create_notification** — Send a notification to the user's Abound app
|
|
||||||
|
|
||||||
You also have **routine_create** for scheduling future/recurring transfers.
|
|
||||||
|
|
||||||
## Workflow: "Send $X" or "Transfer money"
|
|
||||||
|
|
||||||
1. **Always check the rate first.** Call `abound_get_exchange_rate` to get the current rate.
|
|
||||||
2. **Check the forex score.** Call `abound_get_forex_score` to assess timing.
|
|
||||||
3. **Get account info.** Call `abound_get_account_info` to know the user's limits, recipients, and funding sources.
|
|
||||||
4. **Advise based on the score:**
|
|
||||||
- **Score >= 60 (convert_now):** Tell the user it's a good time. Show the rate, the INR equivalent of their amount, and recommend proceeding.
|
|
||||||
- **Score 40-59 (split_transfer):** Suggest splitting — send half now at the current rate, schedule the rest for later when the rate may improve.
|
|
||||||
- **Score < 40 (wait):** Unless the transfer is urgent, recommend waiting. Explain why (rate below average, unfavorable season).
|
|
||||||
5. **Execute if user confirms.** Use `abound_send_wire` with the correct funding_source_id, beneficiary_ref_id, amount, and payment_reason_key from the account info.
|
|
||||||
6. **Notify.** After a successful wire, call `abound_create_notification` with relevant metadata.
|
|
||||||
|
|
||||||
## Workflow: "Schedule a trade" or "Send tomorrow morning"
|
|
||||||
|
|
||||||
1. Gather the same info (rate, score, account).
|
|
||||||
2. Use **routine_create** to schedule the transfer:
|
|
||||||
- For "tomorrow morning": use cron `"0 9 * * *"` with the user's timezone, set to fire once
|
|
||||||
- For "every week": use cron `"0 9 * * MON"` (or the user's preferred day)
|
|
||||||
- The routine prompt should instruct the agent to check the rate and execute the wire
|
|
||||||
3. Confirm the schedule with the user, showing when it will fire.
|
|
||||||
|
|
||||||
## Presentation Rules
|
|
||||||
|
|
||||||
- Always show amounts in **both USD and INR**: "$1,000 (~INR 85,420 at today's rate of 85.42)"
|
|
||||||
- Show the **effective rate** (after fees), not just the market rate
|
|
||||||
- When showing the forex score, explain it simply: "The forex timing score is 72/100 — this is a good time to send."
|
|
||||||
- If the user's amount exceeds their limit ($5,000), tell them and suggest splitting into multiple transfers
|
|
||||||
- Always mention the **estimated delivery time** (1-3 business days) after a wire
|
|
||||||
|
|
||||||
## Payment Reasons
|
|
||||||
|
|
||||||
When asking about the purpose, offer these options:
|
|
||||||
- Family Maintenance
|
|
||||||
- Gift
|
|
||||||
- Education Support
|
|
||||||
- Medical Support
|
|
||||||
|
|
||||||
If the user doesn't specify, ask which applies.
|
|
||||||
+9
-17
@@ -400,21 +400,16 @@ impl<'a> LoopDelegate for ChatDelegate<'a> {
|
|||||||
};
|
};
|
||||||
|
|
||||||
// Record cost and track token usage (global + per-user).
|
// Record cost and track token usage (global + per-user).
|
||||||
// Use the provider's effective_model_name so cost attribution matches
|
// When a model override is active, use the override name for attribution
|
||||||
// the model that actually served the request. When the override is
|
// and let CostGuard look up pricing via costs::model_cost() instead of
|
||||||
// honoured (e.g. NearAI), this returns the override name; when the
|
// using the default provider's cost_per_token (which reflects the wrong model).
|
||||||
// provider ignores overrides (e.g. Rig-based), it returns the active
|
let (model_name, cost_per_token) = if let Some(ref ovr) = reason_ctx.model_override {
|
||||||
// model, keeping attribution accurate in both cases.
|
(ovr.clone(), None)
|
||||||
let model_name = self
|
|
||||||
.agent
|
|
||||||
.llm()
|
|
||||||
.effective_model_name(reason_ctx.model_override.as_deref());
|
|
||||||
let cost_per_token = if reason_ctx.model_override.is_some() {
|
|
||||||
// Override may use different pricing; let CostGuard fall back to
|
|
||||||
// costs::model_cost() for the effective model.
|
|
||||||
None
|
|
||||||
} else {
|
} else {
|
||||||
Some(self.agent.llm().cost_per_token())
|
(
|
||||||
|
self.agent.llm().active_model_name(),
|
||||||
|
Some(self.agent.llm().cost_per_token()),
|
||||||
|
)
|
||||||
};
|
};
|
||||||
let read_discount = self.agent.llm().cache_read_discount();
|
let read_discount = self.agent.llm().cache_read_discount();
|
||||||
let write_multiplier = self.agent.llm().cache_write_multiplier();
|
let write_multiplier = self.agent.llm().cache_write_multiplier();
|
||||||
@@ -1359,7 +1354,6 @@ mod tests {
|
|||||||
max_tool_iterations: 50,
|
max_tool_iterations: 50,
|
||||||
auto_approve_tools: false,
|
auto_approve_tools: false,
|
||||||
default_timezone: "UTC".to_string(),
|
default_timezone: "UTC".to_string(),
|
||||||
max_jobs_per_user: None,
|
|
||||||
max_tokens_per_job: 0,
|
max_tokens_per_job: 0,
|
||||||
multi_tenant: false,
|
multi_tenant: false,
|
||||||
max_llm_concurrent_per_user: None,
|
max_llm_concurrent_per_user: None,
|
||||||
@@ -2241,7 +2235,6 @@ mod tests {
|
|||||||
max_tool_iterations,
|
max_tool_iterations,
|
||||||
auto_approve_tools: true,
|
auto_approve_tools: true,
|
||||||
default_timezone: "UTC".to_string(),
|
default_timezone: "UTC".to_string(),
|
||||||
max_jobs_per_user: None,
|
|
||||||
max_tokens_per_job: 0,
|
max_tokens_per_job: 0,
|
||||||
multi_tenant: false,
|
multi_tenant: false,
|
||||||
max_llm_concurrent_per_user: None,
|
max_llm_concurrent_per_user: None,
|
||||||
@@ -2369,7 +2362,6 @@ mod tests {
|
|||||||
max_tool_iterations: max_iter,
|
max_tool_iterations: max_iter,
|
||||||
auto_approve_tools: true,
|
auto_approve_tools: true,
|
||||||
default_timezone: "UTC".to_string(),
|
default_timezone: "UTC".to_string(),
|
||||||
max_jobs_per_user: None,
|
|
||||||
max_tokens_per_job: 0,
|
max_tokens_per_job: 0,
|
||||||
multi_tenant: false,
|
multi_tenant: false,
|
||||||
max_llm_concurrent_per_user: None,
|
max_llm_concurrent_per_user: None,
|
||||||
|
|||||||
+23
-25
@@ -400,7 +400,7 @@ impl HeartbeatRunner {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Send a notification about heartbeat findings.
|
/// Send a notification about heartbeat findings.
|
||||||
async fn send_notification(&self, message: &str) {
|
pub(crate) async fn send_notification(&self, message: &str) {
|
||||||
let Some(ref tx) = self.response_tx else {
|
let Some(ref tx) = self.response_tx else {
|
||||||
tracing::debug!("No response channel configured for heartbeat notifications");
|
tracing::debug!("No response channel configured for heartbeat notifications");
|
||||||
return;
|
return;
|
||||||
@@ -512,8 +512,8 @@ pub fn spawn_heartbeat(
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Spawn a multi-user heartbeat runner that cycles through all users who
|
/// Spawn a multi-user heartbeat runner that cycles through all users that
|
||||||
/// have routines (enabled or not). Each tick, it queries the DB for distinct
|
/// own routines (enabled or not). Each tick, it queries the DB for distinct
|
||||||
/// user_ids, creates a per-user workspace, and runs a heartbeat check for
|
/// user_ids, creates a per-user workspace, and runs a heartbeat check for
|
||||||
/// each user concurrently. Per-user failure counts are tracked independently.
|
/// each user concurrently. Per-user failure counts are tracked independently.
|
||||||
pub fn spawn_multi_user_heartbeat(
|
pub fn spawn_multi_user_heartbeat(
|
||||||
@@ -574,10 +574,8 @@ pub fn spawn_multi_user_heartbeat(
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Run user heartbeats (and hygiene) concurrently so one slow LLM
|
// Run user heartbeats concurrently so one slow LLM call doesn't
|
||||||
// call doesn't block others. Cap concurrency to avoid flooding the
|
// block others. Cap concurrency to avoid flooding the LLM provider.
|
||||||
// LLM provider. Hygiene runs inside the same JoinSet so it is
|
|
||||||
// tracked and bounded by the same concurrency cap.
|
|
||||||
const MAX_CONCURRENT_HEARTBEATS: usize = 8;
|
const MAX_CONCURRENT_HEARTBEATS: usize = 8;
|
||||||
let mut join_set = tokio::task::JoinSet::new();
|
let mut join_set = tokio::task::JoinSet::new();
|
||||||
|
|
||||||
@@ -590,6 +588,23 @@ pub fn spawn_multi_user_heartbeat(
|
|||||||
|
|
||||||
let workspace = Arc::new(Workspace::new_with_db(user_id, Arc::clone(store.db())));
|
let workspace = Arc::new(Workspace::new_with_db(user_id, Arc::clone(store.db())));
|
||||||
|
|
||||||
|
// Run memory hygiene per user (same as single-user heartbeat).
|
||||||
|
let hygiene_ws = Arc::clone(&workspace);
|
||||||
|
let hygiene_cfg = hygiene_config.clone();
|
||||||
|
let hygiene_user = user_id.clone();
|
||||||
|
tokio::spawn(async move {
|
||||||
|
let report =
|
||||||
|
crate::workspace::hygiene::run_if_due(&hygiene_ws, &hygiene_cfg).await;
|
||||||
|
if report.had_work() {
|
||||||
|
tracing::info!(
|
||||||
|
user_id = hygiene_user,
|
||||||
|
daily_logs_deleted = report.daily_logs_deleted,
|
||||||
|
conversation_docs_deleted = report.conversation_docs_deleted,
|
||||||
|
"multi-user heartbeat: memory hygiene deleted stale documents"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// Drain completed tasks to stay within the concurrency cap.
|
// Drain completed tasks to stay within the concurrency cap.
|
||||||
while join_set.len() >= MAX_CONCURRENT_HEARTBEATS {
|
while join_set.len() >= MAX_CONCURRENT_HEARTBEATS {
|
||||||
if let Some(join_result) = join_set.join_next().await {
|
if let Some(join_result) = join_set.join_next().await {
|
||||||
@@ -598,30 +613,13 @@ pub fn spawn_multi_user_heartbeat(
|
|||||||
}
|
}
|
||||||
|
|
||||||
let uid = user_id.clone();
|
let uid = user_id.clone();
|
||||||
// In multi-tenant mode, clear notify_user_id so that
|
let cfg = config.clone();
|
||||||
// HeartbeatRunner::send_notification falls back to
|
|
||||||
// workspace.user_id() — each user's heartbeat should persist
|
|
||||||
// and notify that user, not the shared config target.
|
|
||||||
let mut cfg = config.clone();
|
|
||||||
cfg.notify_user_id = None;
|
|
||||||
let hyg = hygiene_config.clone();
|
let hyg = hygiene_config.clone();
|
||||||
let llm_clone = llm.clone();
|
let llm_clone = llm.clone();
|
||||||
let tx = response_tx.clone();
|
let tx = response_tx.clone();
|
||||||
let admin = store.clone();
|
let admin = store.clone();
|
||||||
|
|
||||||
join_set.spawn(async move {
|
join_set.spawn(async move {
|
||||||
// Run memory hygiene per user (same as single-user heartbeat)
|
|
||||||
// inside the tracked task so concurrency is bounded.
|
|
||||||
let report = crate::workspace::hygiene::run_if_due(&workspace, &hyg).await;
|
|
||||||
if report.had_work() {
|
|
||||||
tracing::info!(
|
|
||||||
user_id = uid,
|
|
||||||
daily_logs_deleted = report.daily_logs_deleted,
|
|
||||||
conversation_docs_deleted = report.conversation_docs_deleted,
|
|
||||||
"multi-user heartbeat: memory hygiene deleted stale documents"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
let mut runner = HeartbeatRunner::new(cfg, hyg, workspace, llm_clone);
|
let mut runner = HeartbeatRunner::new(cfg, hyg, workspace, llm_clone);
|
||||||
if let Some(tx) = tx {
|
if let Some(tx) = tx {
|
||||||
runner = runner.with_response_channel(tx);
|
runner = runner.with_response_channel(tx);
|
||||||
|
|||||||
+439
-2
@@ -24,6 +24,8 @@ use std::time::Duration;
|
|||||||
|
|
||||||
use chrono::{DateTime, Utc};
|
use chrono::{DateTime, Utc};
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
use serde_json::{Map, Value};
|
||||||
|
use sha2::{Digest, Sha256};
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
use crate::error::RoutineError;
|
use crate::error::RoutineError;
|
||||||
@@ -52,6 +54,55 @@ pub struct Routine {
|
|||||||
pub updated_at: DateTime<Utc>,
|
pub updated_at: DateTime<Utc>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const ROUTINE_VERIFICATION_STATE_KEY: &str = "_verification";
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
struct RoutineVerificationRecord {
|
||||||
|
current_fingerprint: String,
|
||||||
|
#[serde(default)]
|
||||||
|
verified_fingerprint: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
last_verified_at: Option<DateTime<Utc>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub enum RoutineVerificationStatus {
|
||||||
|
Verified,
|
||||||
|
Unverified,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl RoutineVerificationStatus {
|
||||||
|
pub fn as_str(self) -> &'static str {
|
||||||
|
match self {
|
||||||
|
RoutineVerificationStatus::Verified => "verified",
|
||||||
|
RoutineVerificationStatus::Unverified => "unverified",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub enum RoutineDisplayStatus {
|
||||||
|
Disabled,
|
||||||
|
Running,
|
||||||
|
Unverified,
|
||||||
|
Failing,
|
||||||
|
Attention,
|
||||||
|
Active,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl RoutineDisplayStatus {
|
||||||
|
pub fn as_str(self) -> &'static str {
|
||||||
|
match self {
|
||||||
|
RoutineDisplayStatus::Disabled => "disabled",
|
||||||
|
RoutineDisplayStatus::Running => "running",
|
||||||
|
RoutineDisplayStatus::Unverified => "unverified",
|
||||||
|
RoutineDisplayStatus::Failing => "failing",
|
||||||
|
RoutineDisplayStatus::Attention => "attention",
|
||||||
|
RoutineDisplayStatus::Active => "active",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// When a routine should fire.
|
/// When a routine should fire.
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
#[serde(tag = "type", rename_all = "snake_case")]
|
#[serde(tag = "type", rename_all = "snake_case")]
|
||||||
@@ -517,6 +568,155 @@ pub fn content_hash(content: &str) -> u64 {
|
|||||||
hasher.finish()
|
hasher.finish()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn routine_state_as_object(state: &Value) -> Map<String, Value> {
|
||||||
|
state.as_object().cloned().unwrap_or_default()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn routine_verification_record(state: &Value) -> Option<RoutineVerificationRecord> {
|
||||||
|
state
|
||||||
|
.as_object()
|
||||||
|
.and_then(|obj| obj.get(ROUTINE_VERIFICATION_STATE_KEY))
|
||||||
|
.cloned()
|
||||||
|
.and_then(|value| serde_json::from_value(value).ok())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn write_routine_verification_record(
|
||||||
|
state: &Value,
|
||||||
|
record: RoutineVerificationRecord,
|
||||||
|
) -> serde_json::Value {
|
||||||
|
let mut obj = routine_state_as_object(state);
|
||||||
|
if let Ok(value) = serde_json::to_value(record) {
|
||||||
|
obj.insert(ROUTINE_VERIFICATION_STATE_KEY.to_string(), value);
|
||||||
|
}
|
||||||
|
Value::Object(obj)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn canonicalize_json_value(value: Value) -> Value {
|
||||||
|
match value {
|
||||||
|
Value::Array(items) => {
|
||||||
|
Value::Array(items.into_iter().map(canonicalize_json_value).collect())
|
||||||
|
}
|
||||||
|
Value::Object(obj) => {
|
||||||
|
let mut keys: Vec<String> = obj.keys().cloned().collect();
|
||||||
|
keys.sort();
|
||||||
|
let mut canonical = Map::new();
|
||||||
|
for key in keys {
|
||||||
|
if let Some(value) = obj.get(&key) {
|
||||||
|
canonical.insert(key, canonicalize_json_value(value.clone()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Value::Object(canonical)
|
||||||
|
}
|
||||||
|
other => other,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn routine_verification_fingerprint(routine: &Routine) -> String {
|
||||||
|
let canonical = canonicalize_json_value(serde_json::json!({
|
||||||
|
"trigger_type": routine.trigger.type_tag(),
|
||||||
|
"trigger": routine.trigger.to_config_json(),
|
||||||
|
"action_type": routine.action.type_tag(),
|
||||||
|
"action": routine.action.to_config_json(),
|
||||||
|
"guardrails": {
|
||||||
|
"cooldown_secs": routine.guardrails.cooldown.as_secs(),
|
||||||
|
"max_concurrent": routine.guardrails.max_concurrent,
|
||||||
|
"dedup_window_secs": routine.guardrails.dedup_window.map(|d| d.as_secs()),
|
||||||
|
},
|
||||||
|
}))
|
||||||
|
.to_string();
|
||||||
|
let mut hasher = Sha256::new();
|
||||||
|
hasher.update(canonical.as_bytes());
|
||||||
|
hex::encode(hasher.finalize())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn reset_routine_verification_state(
|
||||||
|
state: &Value,
|
||||||
|
current_fingerprint: String,
|
||||||
|
) -> serde_json::Value {
|
||||||
|
let mut record = routine_verification_record(state).unwrap_or(RoutineVerificationRecord {
|
||||||
|
current_fingerprint: current_fingerprint.clone(),
|
||||||
|
verified_fingerprint: None,
|
||||||
|
last_verified_at: None,
|
||||||
|
});
|
||||||
|
record.current_fingerprint = current_fingerprint;
|
||||||
|
write_routine_verification_record(state, record)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn apply_routine_verification_result(
|
||||||
|
state: &Value,
|
||||||
|
current_fingerprint: String,
|
||||||
|
status: RunStatus,
|
||||||
|
now: DateTime<Utc>,
|
||||||
|
) -> serde_json::Value {
|
||||||
|
if let Some(mut record) = routine_verification_record(state) {
|
||||||
|
record.current_fingerprint = current_fingerprint.clone();
|
||||||
|
if status == RunStatus::Ok {
|
||||||
|
record.verified_fingerprint = Some(current_fingerprint);
|
||||||
|
record.last_verified_at = Some(now);
|
||||||
|
}
|
||||||
|
write_routine_verification_record(state, record)
|
||||||
|
} else if status == RunStatus::Ok {
|
||||||
|
write_routine_verification_record(
|
||||||
|
state,
|
||||||
|
RoutineVerificationRecord {
|
||||||
|
current_fingerprint: current_fingerprint.clone(),
|
||||||
|
verified_fingerprint: Some(current_fingerprint),
|
||||||
|
last_verified_at: Some(now),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
state.clone()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn routine_verification_status(routine: &Routine) -> RoutineVerificationStatus {
|
||||||
|
let fingerprint = routine_verification_fingerprint(routine);
|
||||||
|
let verified =
|
||||||
|
routine_verification_record(&routine.state).map_or(routine.run_count > 0, |record| {
|
||||||
|
record.current_fingerprint == fingerprint
|
||||||
|
&& record.verified_fingerprint.as_deref() == Some(fingerprint.as_str())
|
||||||
|
});
|
||||||
|
if verified {
|
||||||
|
RoutineVerificationStatus::Verified
|
||||||
|
} else {
|
||||||
|
RoutineVerificationStatus::Unverified
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn routine_display_status(
|
||||||
|
routine: &Routine,
|
||||||
|
last_run_status: Option<RunStatus>,
|
||||||
|
) -> RoutineDisplayStatus {
|
||||||
|
routine_display_status_for_verification(
|
||||||
|
routine,
|
||||||
|
routine_verification_status(routine),
|
||||||
|
last_run_status,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn routine_display_status_for_verification(
|
||||||
|
routine: &Routine,
|
||||||
|
verification_status: RoutineVerificationStatus,
|
||||||
|
last_run_status: Option<RunStatus>,
|
||||||
|
) -> RoutineDisplayStatus {
|
||||||
|
if !routine.enabled {
|
||||||
|
return RoutineDisplayStatus::Disabled;
|
||||||
|
}
|
||||||
|
if last_run_status == Some(RunStatus::Running) {
|
||||||
|
return RoutineDisplayStatus::Running;
|
||||||
|
}
|
||||||
|
if verification_status == RoutineVerificationStatus::Unverified {
|
||||||
|
return RoutineDisplayStatus::Unverified;
|
||||||
|
}
|
||||||
|
if routine.consecutive_failures > 0 {
|
||||||
|
return RoutineDisplayStatus::Failing;
|
||||||
|
}
|
||||||
|
if last_run_status == Some(RunStatus::Attention) {
|
||||||
|
return RoutineDisplayStatus::Attention;
|
||||||
|
}
|
||||||
|
RoutineDisplayStatus::Active
|
||||||
|
}
|
||||||
|
|
||||||
/// Normalize a cron expression to the 7-field format expected by the `cron` crate.
|
/// Normalize a cron expression to the 7-field format expected by the `cron` crate.
|
||||||
///
|
///
|
||||||
/// The `cron` crate requires: `sec min hour day-of-month month day-of-week year`.
|
/// The `cron` crate requires: `sec min hour day-of-month month day-of-week year`.
|
||||||
@@ -725,9 +925,14 @@ pub fn describe_cron(schedule: &str, timezone: Option<&str>) -> String {
|
|||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use crate::agent::routine::{
|
use crate::agent::routine::{
|
||||||
MAX_TOOL_ROUNDS_LIMIT, RoutineAction, RoutineGuardrails, RunStatus, Trigger, content_hash,
|
MAX_TOOL_ROUNDS_LIMIT, NotifyConfig, Routine, RoutineAction, RoutineGuardrails,
|
||||||
describe_cron, next_cron_fire, normalize_cron_expression,
|
RoutineVerificationStatus, RunStatus, Trigger, apply_routine_verification_result,
|
||||||
|
content_hash, describe_cron, next_cron_fire, normalize_cron_expression,
|
||||||
|
reset_routine_verification_state, routine_verification_fingerprint,
|
||||||
|
routine_verification_status,
|
||||||
};
|
};
|
||||||
|
use chrono::Utc;
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_trigger_roundtrip() {
|
fn test_trigger_roundtrip() {
|
||||||
@@ -861,6 +1066,69 @@ mod tests {
|
|||||||
assert_ne!(h1, h3);
|
assert_ne!(h1, h3);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_verification_fingerprint_is_digest_not_prompt_content() {
|
||||||
|
let routine = Routine {
|
||||||
|
id: Uuid::new_v4(),
|
||||||
|
name: "hashed".to_string(),
|
||||||
|
description: "hash test".to_string(),
|
||||||
|
user_id: "test-user".to_string(),
|
||||||
|
enabled: true,
|
||||||
|
trigger: Trigger::Manual,
|
||||||
|
action: RoutineAction::Lightweight {
|
||||||
|
prompt: "super-secret-routine-prompt".to_string(),
|
||||||
|
context_paths: Vec::new(),
|
||||||
|
max_tokens: 256,
|
||||||
|
use_tools: false,
|
||||||
|
max_tool_rounds: 1,
|
||||||
|
},
|
||||||
|
guardrails: RoutineGuardrails::default(),
|
||||||
|
notify: NotifyConfig::default(),
|
||||||
|
last_run_at: None,
|
||||||
|
next_fire_at: None,
|
||||||
|
run_count: 0,
|
||||||
|
consecutive_failures: 0,
|
||||||
|
state: serde_json::json!({}),
|
||||||
|
created_at: Utc::now(),
|
||||||
|
updated_at: Utc::now(),
|
||||||
|
};
|
||||||
|
|
||||||
|
let fingerprint = routine_verification_fingerprint(&routine);
|
||||||
|
|
||||||
|
assert_eq!(fingerprint.len(), 64);
|
||||||
|
assert!(!fingerprint.contains("super-secret-routine-prompt"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_system_event_fingerprint_is_stable_when_filter_insertion_order_differs() {
|
||||||
|
let mut first_filters = std::collections::HashMap::new();
|
||||||
|
first_filters.insert("repo".to_string(), "nearai/ironclaw".to_string());
|
||||||
|
first_filters.insert("action".to_string(), "opened".to_string());
|
||||||
|
|
||||||
|
let mut second_filters = std::collections::HashMap::new();
|
||||||
|
second_filters.insert("action".to_string(), "opened".to_string());
|
||||||
|
second_filters.insert("repo".to_string(), "nearai/ironclaw".to_string());
|
||||||
|
|
||||||
|
let mut first = make_verification_test_routine();
|
||||||
|
first.trigger = Trigger::SystemEvent {
|
||||||
|
source: "github".to_string(),
|
||||||
|
event_type: "issue".to_string(),
|
||||||
|
filters: first_filters,
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut second = make_verification_test_routine();
|
||||||
|
second.trigger = Trigger::SystemEvent {
|
||||||
|
source: "github".to_string(),
|
||||||
|
event_type: "issue".to_string(),
|
||||||
|
filters: second_filters,
|
||||||
|
};
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
routine_verification_fingerprint(&first),
|
||||||
|
routine_verification_fingerprint(&second)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_next_cron_fire_valid() {
|
fn test_next_cron_fire_valid() {
|
||||||
// Every minute should always have a next fire
|
// Every minute should always have a next fire
|
||||||
@@ -1117,4 +1385,173 @@ mod tests {
|
|||||||
_ => panic!("expected Lightweight"),
|
_ => panic!("expected Lightweight"),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn make_verification_test_routine() -> Routine {
|
||||||
|
Routine {
|
||||||
|
id: Uuid::new_v4(),
|
||||||
|
name: "verify-me".to_string(),
|
||||||
|
description: "verification test".to_string(),
|
||||||
|
user_id: "test-user".to_string(),
|
||||||
|
enabled: true,
|
||||||
|
trigger: Trigger::Manual,
|
||||||
|
action: RoutineAction::Lightweight {
|
||||||
|
prompt: "Check routine output".to_string(),
|
||||||
|
context_paths: Vec::new(),
|
||||||
|
max_tokens: 1024,
|
||||||
|
use_tools: false,
|
||||||
|
max_tool_rounds: 1,
|
||||||
|
},
|
||||||
|
guardrails: RoutineGuardrails::default(),
|
||||||
|
notify: NotifyConfig::default(),
|
||||||
|
last_run_at: None,
|
||||||
|
next_fire_at: None,
|
||||||
|
run_count: 0,
|
||||||
|
consecutive_failures: 0,
|
||||||
|
state: serde_json::json!({}),
|
||||||
|
created_at: Utc::now(),
|
||||||
|
updated_at: Utc::now(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_reset_verification_state_marks_new_routine_unverified() {
|
||||||
|
let mut routine = make_verification_test_routine();
|
||||||
|
routine.state = reset_routine_verification_state(
|
||||||
|
&routine.state,
|
||||||
|
routine_verification_fingerprint(&routine),
|
||||||
|
);
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
routine_verification_status(&routine),
|
||||||
|
RoutineVerificationStatus::Unverified
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_successful_run_verifies_current_fingerprint() {
|
||||||
|
let mut routine = make_verification_test_routine();
|
||||||
|
let fingerprint = routine_verification_fingerprint(&routine);
|
||||||
|
routine.state = reset_routine_verification_state(&routine.state, fingerprint.clone());
|
||||||
|
routine.state = apply_routine_verification_result(
|
||||||
|
&routine.state,
|
||||||
|
fingerprint,
|
||||||
|
RunStatus::Ok,
|
||||||
|
Utc::now(),
|
||||||
|
);
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
routine_verification_status(&routine),
|
||||||
|
RoutineVerificationStatus::Verified
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_behavior_change_resets_prior_verification() {
|
||||||
|
let mut routine = make_verification_test_routine();
|
||||||
|
let original_fingerprint = routine_verification_fingerprint(&routine);
|
||||||
|
routine.state =
|
||||||
|
reset_routine_verification_state(&routine.state, original_fingerprint.clone());
|
||||||
|
routine.state = apply_routine_verification_result(
|
||||||
|
&routine.state,
|
||||||
|
original_fingerprint,
|
||||||
|
RunStatus::Ok,
|
||||||
|
Utc::now(),
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
routine_verification_status(&routine),
|
||||||
|
RoutineVerificationStatus::Verified
|
||||||
|
);
|
||||||
|
|
||||||
|
if let RoutineAction::Lightweight { prompt, .. } = &mut routine.action {
|
||||||
|
*prompt = "Updated prompt".to_string();
|
||||||
|
}
|
||||||
|
routine.state = reset_routine_verification_state(
|
||||||
|
&routine.state,
|
||||||
|
routine_verification_fingerprint(&routine),
|
||||||
|
);
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
routine_verification_status(&routine),
|
||||||
|
RoutineVerificationStatus::Unverified
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_failed_unverified_run_stays_unverified() {
|
||||||
|
let mut routine = make_verification_test_routine();
|
||||||
|
let fingerprint = routine_verification_fingerprint(&routine);
|
||||||
|
routine.state = reset_routine_verification_state(&routine.state, fingerprint.clone());
|
||||||
|
routine.state = apply_routine_verification_result(
|
||||||
|
&routine.state,
|
||||||
|
fingerprint,
|
||||||
|
RunStatus::Failed,
|
||||||
|
Utc::now(),
|
||||||
|
);
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
routine_verification_status(&routine),
|
||||||
|
RoutineVerificationStatus::Unverified
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_schedule_change_resets_verification() {
|
||||||
|
let mut routine = make_verification_test_routine();
|
||||||
|
routine.trigger = Trigger::Cron {
|
||||||
|
schedule: "0 0 9 * * MON-FRI *".to_string(),
|
||||||
|
timezone: Some("UTC".to_string()),
|
||||||
|
};
|
||||||
|
let original_fingerprint = routine_verification_fingerprint(&routine);
|
||||||
|
routine.state =
|
||||||
|
reset_routine_verification_state(&routine.state, original_fingerprint.clone());
|
||||||
|
routine.state = apply_routine_verification_result(
|
||||||
|
&routine.state,
|
||||||
|
original_fingerprint,
|
||||||
|
RunStatus::Ok,
|
||||||
|
Utc::now(),
|
||||||
|
);
|
||||||
|
|
||||||
|
routine.trigger = Trigger::Cron {
|
||||||
|
schedule: "0 0 10 * * MON-FRI *".to_string(),
|
||||||
|
timezone: Some("UTC".to_string()),
|
||||||
|
};
|
||||||
|
routine.state = reset_routine_verification_state(
|
||||||
|
&routine.state,
|
||||||
|
routine_verification_fingerprint(&routine),
|
||||||
|
);
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
routine_verification_status(&routine),
|
||||||
|
RoutineVerificationStatus::Unverified
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_legacy_routine_with_runs_is_treated_as_verified_without_metadata() {
|
||||||
|
let mut routine = make_verification_test_routine();
|
||||||
|
routine.run_count = 3;
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
routine_verification_status(&routine),
|
||||||
|
RoutineVerificationStatus::Verified
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_failed_legacy_run_preserves_implicit_verification() {
|
||||||
|
let mut routine = make_verification_test_routine();
|
||||||
|
routine.run_count = 2;
|
||||||
|
let fingerprint = routine_verification_fingerprint(&routine);
|
||||||
|
routine.state = apply_routine_verification_result(
|
||||||
|
&routine.state,
|
||||||
|
fingerprint,
|
||||||
|
RunStatus::Failed,
|
||||||
|
Utc::now(),
|
||||||
|
);
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
routine_verification_status(&routine),
|
||||||
|
RoutineVerificationStatus::Verified
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -23,7 +23,8 @@ use uuid::Uuid;
|
|||||||
|
|
||||||
use crate::agent::Scheduler;
|
use crate::agent::Scheduler;
|
||||||
use crate::agent::routine::{
|
use crate::agent::routine::{
|
||||||
NotifyConfig, Routine, RoutineAction, RoutineRun, RunStatus, Trigger, next_cron_fire,
|
NotifyConfig, Routine, RoutineAction, RoutineRun, RunStatus, Trigger,
|
||||||
|
apply_routine_verification_result, next_cron_fire, routine_verification_fingerprint,
|
||||||
};
|
};
|
||||||
use crate::channels::{IncomingMessage, OutgoingResponse};
|
use crate::channels::{IncomingMessage, OutgoingResponse};
|
||||||
use crate::config::RoutineConfig;
|
use crate::config::RoutineConfig;
|
||||||
@@ -621,7 +622,7 @@ impl RoutineEngine {
|
|||||||
);
|
);
|
||||||
|
|
||||||
// Load the routine to update consecutive_failures and send notification
|
// Load the routine to update consecutive_failures and send notification
|
||||||
let routine = match self.store.get_routine(run.routine_id).await {
|
let mut routine = match self.store.get_routine(run.routine_id).await {
|
||||||
Ok(Some(r)) => r,
|
Ok(Some(r)) => r,
|
||||||
Ok(None) => {
|
Ok(None) => {
|
||||||
tracing::warn!(
|
tracing::warn!(
|
||||||
@@ -649,6 +650,12 @@ impl RoutineEngine {
|
|||||||
};
|
};
|
||||||
|
|
||||||
let now = Utc::now();
|
let now = Utc::now();
|
||||||
|
routine.state = apply_routine_verification_result(
|
||||||
|
&routine.state,
|
||||||
|
routine_verification_fingerprint(&routine),
|
||||||
|
status,
|
||||||
|
now,
|
||||||
|
);
|
||||||
let next_fire = if let Trigger::Cron {
|
let next_fire = if let Trigger::Cron {
|
||||||
ref schedule,
|
ref schedule,
|
||||||
ref timezone,
|
ref timezone,
|
||||||
@@ -1085,7 +1092,7 @@ struct EngineContext {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Execute a routine run. Handles both lightweight and full_job modes.
|
/// Execute a routine run. Handles both lightweight and full_job modes.
|
||||||
async fn execute_routine(ctx: EngineContext, routine: Routine, run: RoutineRun) {
|
async fn execute_routine(ctx: EngineContext, mut routine: Routine, run: RoutineRun) {
|
||||||
// Increment running count (atomic: survives panics in the execution below)
|
// Increment running count (atomic: survives panics in the execution below)
|
||||||
ctx.running_count.fetch_add(1, Ordering::Relaxed);
|
ctx.running_count.fetch_add(1, Ordering::Relaxed);
|
||||||
|
|
||||||
@@ -1143,8 +1150,15 @@ async fn execute_routine(ctx: EngineContext, routine: Routine, run: RoutineRun)
|
|||||||
tracing::error!(routine = %routine.name, "Failed to complete run record: {}", e);
|
tracing::error!(routine = %routine.name, "Failed to complete run record: {}", e);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update routine runtime state
|
|
||||||
let now = Utc::now();
|
let now = Utc::now();
|
||||||
|
routine.state = apply_routine_verification_result(
|
||||||
|
&routine.state,
|
||||||
|
routine_verification_fingerprint(&routine),
|
||||||
|
status,
|
||||||
|
now,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Update routine runtime state
|
||||||
let next_fire = if let Trigger::Cron {
|
let next_fire = if let Trigger::Cron {
|
||||||
ref schedule,
|
ref schedule,
|
||||||
ref timezone,
|
ref timezone,
|
||||||
|
|||||||
@@ -267,20 +267,6 @@ impl Scheduler {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Per-user concurrency check
|
|
||||||
if let Some(max_per_user) = self.config.max_jobs_per_user
|
|
||||||
&& let Ok(ctx) = self.context_manager.get_context(job_id).await
|
|
||||||
{
|
|
||||||
let user_active = self
|
|
||||||
.context_manager
|
|
||||||
.active_jobs_for(&ctx.user_id)
|
|
||||||
.await
|
|
||||||
.len();
|
|
||||||
if user_active >= max_per_user {
|
|
||||||
return Err(JobError::MaxJobsExceeded { max: max_per_user });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Transition job to in_progress
|
// Transition job to in_progress
|
||||||
self.context_manager
|
self.context_manager
|
||||||
.update_context(job_id, |ctx| {
|
.update_context(job_id, |ctx| {
|
||||||
@@ -798,7 +784,6 @@ mod tests {
|
|||||||
max_tool_iterations: 10,
|
max_tool_iterations: 10,
|
||||||
auto_approve_tools: true,
|
auto_approve_tools: true,
|
||||||
default_timezone: "UTC".to_string(),
|
default_timezone: "UTC".to_string(),
|
||||||
max_jobs_per_user: None,
|
|
||||||
max_tokens_per_job,
|
max_tokens_per_job,
|
||||||
multi_tenant: false,
|
multi_tenant: false,
|
||||||
max_llm_concurrent_per_user: None,
|
max_llm_concurrent_per_user: None,
|
||||||
|
|||||||
+7
-2
@@ -336,12 +336,17 @@ impl AppBuilder {
|
|||||||
ws = ws.with_memory_layers(self.config.workspace.memory_layers.clone());
|
ws = ws.with_memory_layers(self.config.workspace.memory_layers.clone());
|
||||||
let ws = Arc::new(ws);
|
let ws = Arc::new(ws);
|
||||||
|
|
||||||
// Detect multi-tenant mode: when the database has registered users,
|
// Detect multi-tenant mode: when GATEWAY_USER_TOKENS is configured,
|
||||||
// each authenticated user needs their own workspace scope. Use
|
// each authenticated user needs their own workspace scope. Use
|
||||||
// WorkspacePool (which implements WorkspaceResolver) to create
|
// WorkspacePool (which implements WorkspaceResolver) to create
|
||||||
// per-user workspaces on demand instead of sharing the startup
|
// per-user workspaces on demand instead of sharing the startup
|
||||||
// workspace across all users.
|
// workspace across all users.
|
||||||
let is_multi_tenant = db.has_any_users().await.unwrap_or(false);
|
let is_multi_tenant = self
|
||||||
|
.config
|
||||||
|
.channels
|
||||||
|
.gateway
|
||||||
|
.as_ref()
|
||||||
|
.is_some_and(|gw| gw.user_tokens.is_some());
|
||||||
|
|
||||||
if is_multi_tenant {
|
if is_multi_tenant {
|
||||||
let pool = Arc::new(crate::channels::web::server::WorkspacePool::new(
|
let pool = Arc::new(crate::channels::web::server::WorkspacePool::new(
|
||||||
|
|||||||
@@ -84,41 +84,13 @@ Browser-facing HTTP API and SSE/WebSocket real-time streaming. Axum-based, singl
|
|||||||
| Method | Path | Description |
|
| Method | Path | Description |
|
||||||
|--------|------|-------------|
|
|--------|------|-------------|
|
||||||
| GET | `/api/routines` | List routines |
|
| GET | `/api/routines` | List routines |
|
||||||
| GET | `/api/routines/summary` | Aggregated stats (total/enabled/disabled/failing/runs_today) |
|
| GET | `/api/routines/summary` | Aggregated stats (total/enabled/disabled/unverified/failing/runs_today) |
|
||||||
| GET | `/api/routines/{id}` | Routine detail with recent run history |
|
| GET | `/api/routines/{id}` | Routine detail with recent run history |
|
||||||
| POST | `/api/routines/{id}/trigger` | Manually trigger a routine |
|
| POST | `/api/routines/{id}/trigger` | Manually trigger a routine |
|
||||||
| POST | `/api/routines/{id}/toggle` | Enable/disable a routine |
|
| POST | `/api/routines/{id}/toggle` | Enable/disable a routine |
|
||||||
| DELETE | `/api/routines/{id}` | Delete a routine |
|
| DELETE | `/api/routines/{id}` | Delete a routine |
|
||||||
| GET | `/api/routines/{id}/runs` | List runs for a specific routine |
|
| GET | `/api/routines/{id}/runs` | List runs for a specific routine |
|
||||||
|
|
||||||
### User Management (admin — requires `admin` role, see `docs/USER_MANAGEMENT_API.md`)
|
|
||||||
| Method | Path | Description |
|
|
||||||
|--------|------|-------------|
|
|
||||||
| POST | `/api/admin/users` | Create a new user (returns one-time token) |
|
|
||||||
| GET | `/api/admin/users` | List all users |
|
|
||||||
| GET | `/api/admin/users/{id}` | Get a single user |
|
|
||||||
| PATCH | `/api/admin/users/{id}` | Update user profile/metadata |
|
|
||||||
| DELETE | `/api/admin/users/{id}` | Delete user and all data |
|
|
||||||
| POST | `/api/admin/users/{id}/suspend` | Suspend a user |
|
|
||||||
| POST | `/api/admin/users/{id}/activate` | Re-activate a user |
|
|
||||||
| GET | `/api/admin/usage` | Per-user LLM usage stats |
|
|
||||||
| GET | `/api/admin/users/{id}/secrets` | List a user's secrets (names only) |
|
|
||||||
| PUT | `/api/admin/users/{id}/secrets/{name}` | Create or update a user's secret |
|
|
||||||
| DELETE | `/api/admin/users/{id}/secrets/{name}` | Delete a user's secret |
|
|
||||||
|
|
||||||
### Profile (self-service)
|
|
||||||
| Method | Path | Description |
|
|
||||||
|--------|------|-------------|
|
|
||||||
| GET | `/api/profile` | Get own profile |
|
|
||||||
| PATCH | `/api/profile` | Update own display name/metadata |
|
|
||||||
|
|
||||||
### Tokens (self-service)
|
|
||||||
| Method | Path | Description |
|
|
||||||
|--------|------|-------------|
|
|
||||||
| POST | `/api/tokens` | Create API token (returns plaintext once) |
|
|
||||||
| GET | `/api/tokens` | List own tokens |
|
|
||||||
| DELETE | `/api/tokens/{id}` | Revoke a token |
|
|
||||||
|
|
||||||
### Settings
|
### Settings
|
||||||
| Method | Path | Description |
|
| Method | Path | Description |
|
||||||
|--------|------|-------------|
|
|--------|------|-------------|
|
||||||
|
|||||||
+20
-201
@@ -5,7 +5,6 @@
|
|||||||
//! handlers can extract it via `AuthenticatedUser`.
|
//! handlers can extract it via `AuthenticatedUser`.
|
||||||
|
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
use std::num::NonZeroUsize;
|
|
||||||
|
|
||||||
use axum::{
|
use axum::{
|
||||||
extract::{FromRequestParts, Request, State},
|
extract::{FromRequestParts, Request, State},
|
||||||
@@ -14,25 +13,18 @@ use axum::{
|
|||||||
response::{IntoResponse, Response},
|
response::{IntoResponse, Response},
|
||||||
};
|
};
|
||||||
use sha2::{Digest, Sha256};
|
use sha2::{Digest, Sha256};
|
||||||
use std::sync::Arc;
|
|
||||||
use std::time::Instant;
|
|
||||||
use subtle::ConstantTimeEq;
|
use subtle::ConstantTimeEq;
|
||||||
use tokio::sync::RwLock;
|
|
||||||
|
|
||||||
use crate::db::Database;
|
|
||||||
|
|
||||||
/// Identity resolved from a bearer token.
|
/// Identity resolved from a bearer token.
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
pub struct UserIdentity {
|
pub struct UserIdentity {
|
||||||
pub user_id: String,
|
pub user_id: String,
|
||||||
/// `admin` or `member`.
|
|
||||||
pub role: String,
|
|
||||||
/// Additional user scopes this identity can read from.
|
/// Additional user scopes this identity can read from.
|
||||||
pub workspace_read_scopes: Vec<String>,
|
pub workspace_read_scopes: Vec<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Hash a token with SHA-256 for constant-size, timing-safe storage.
|
/// Hash a token with SHA-256 for constant-size, timing-safe storage.
|
||||||
pub fn hash_token(token: &str) -> [u8; 32] {
|
fn hash_token(token: &str) -> [u8; 32] {
|
||||||
let mut hasher = Sha256::new();
|
let mut hasher = Sha256::new();
|
||||||
hasher.update(token.as_bytes());
|
hasher.update(token.as_bytes());
|
||||||
hasher.finalize().into()
|
hasher.finalize().into()
|
||||||
@@ -64,7 +56,6 @@ impl MultiAuthState {
|
|||||||
hash,
|
hash,
|
||||||
UserIdentity {
|
UserIdentity {
|
||||||
user_id,
|
user_id,
|
||||||
role: "admin".to_string(),
|
|
||||||
workspace_read_scopes: Vec::new(),
|
workspace_read_scopes: Vec::new(),
|
||||||
},
|
},
|
||||||
)],
|
)],
|
||||||
@@ -73,11 +64,6 @@ impl MultiAuthState {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Create a multi-user auth state from a map of tokens to identities.
|
/// Create a multi-user auth state from a map of tokens to identities.
|
||||||
///
|
|
||||||
/// **Test-only** — production multi-user auth is DB-backed via
|
|
||||||
/// `DbAuthenticator`. This constructor is kept public (not `#[cfg(test)]`)
|
|
||||||
/// because integration tests in `tests/` compile the crate as a library
|
|
||||||
/// where `cfg(test)` is not set.
|
|
||||||
pub fn multi(tokens: HashMap<String, UserIdentity>) -> Self {
|
pub fn multi(tokens: HashMap<String, UserIdentity>) -> Self {
|
||||||
let hashed_tokens: Vec<([u8; 32], UserIdentity)> = tokens
|
let hashed_tokens: Vec<([u8; 32], UserIdentity)> = tokens
|
||||||
.into_iter()
|
.into_iter()
|
||||||
@@ -122,112 +108,6 @@ impl MultiAuthState {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// DB-backed token authenticator with a bounded LRU cache.
|
|
||||||
///
|
|
||||||
/// Checks an LRU cache first (TTL 60s), then falls back to a DB query.
|
|
||||||
/// The cache is bounded to `MAX_CACHE_ENTRIES` — when full, the least
|
|
||||||
/// recently used entry is evicted regardless of TTL.
|
|
||||||
///
|
|
||||||
/// Revoking a token or suspending a user has at most 60s of stale
|
|
||||||
/// authentication before the cache entry expires.
|
|
||||||
#[derive(Clone)]
|
|
||||||
#[allow(clippy::type_complexity)]
|
|
||||||
pub struct DbAuthenticator {
|
|
||||||
store: Arc<dyn Database>,
|
|
||||||
/// Bounded LRU cache: token_hash → (identity, inserted_at).
|
|
||||||
cache: Arc<RwLock<lru::LruCache<[u8; 32], (UserIdentity, Instant)>>>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl DbAuthenticator {
|
|
||||||
/// Cache TTL — how long a successful auth is cached before re-querying the DB.
|
|
||||||
const CACHE_TTL_SECS: u64 = 60;
|
|
||||||
/// Maximum cache entries to prevent unbounded growth.
|
|
||||||
// SAFETY: 1024 is non-zero, so the unwrap in `new()` is infallible.
|
|
||||||
const MAX_CACHE_ENTRIES: NonZeroUsize = match NonZeroUsize::new(1024) {
|
|
||||||
Some(v) => v,
|
|
||||||
None => unreachable!(),
|
|
||||||
};
|
|
||||||
|
|
||||||
pub fn new(store: Arc<dyn Database>) -> Self {
|
|
||||||
Self {
|
|
||||||
store,
|
|
||||||
cache: Arc::new(RwLock::new(lru::LruCache::new(Self::MAX_CACHE_ENTRIES))),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Authenticate a token against the database, using cache when possible.
|
|
||||||
///
|
|
||||||
/// Returns `Ok(Some(identity))` on success, `Ok(None)` if the token is
|
|
||||||
/// not found, or `Err(())` if the database is unreachable (so the caller
|
|
||||||
/// can return 503 instead of 401).
|
|
||||||
pub async fn authenticate(&self, candidate: &str) -> Result<Option<UserIdentity>, ()> {
|
|
||||||
let hash = hash_token(candidate);
|
|
||||||
|
|
||||||
// Check cache first (promotes to most-recent on hit)
|
|
||||||
{
|
|
||||||
let mut cache = self.cache.write().await;
|
|
||||||
if let Some((identity, inserted_at)) = cache.get(&hash) {
|
|
||||||
if inserted_at.elapsed().as_secs() < Self::CACHE_TTL_SECS {
|
|
||||||
return Ok(Some(identity.clone()));
|
|
||||||
}
|
|
||||||
// Expired — remove stale entry
|
|
||||||
cache.pop(&hash);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Cache miss or expired — query DB
|
|
||||||
let (token_record, user_record) = match self.store.authenticate_token(&hash).await {
|
|
||||||
Ok(Some(pair)) => pair,
|
|
||||||
Ok(None) => return Ok(None),
|
|
||||||
Err(e) => {
|
|
||||||
tracing::error!(error = %e, "DB auth lookup failed, returning 503");
|
|
||||||
return Err(());
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
let identity = UserIdentity {
|
|
||||||
user_id: user_record.id.clone(),
|
|
||||||
role: user_record.role.clone(),
|
|
||||||
workspace_read_scopes: Vec::new(),
|
|
||||||
};
|
|
||||||
|
|
||||||
// Record token usage (best-effort, don't block auth)
|
|
||||||
let store = self.store.clone();
|
|
||||||
let token_id = token_record.id;
|
|
||||||
let user_id = user_record.id;
|
|
||||||
tokio::spawn(async move {
|
|
||||||
let _ = store.record_token_usage(token_id).await;
|
|
||||||
let _ = store.record_login(&user_id).await;
|
|
||||||
});
|
|
||||||
|
|
||||||
// Insert into bounded LRU — if full, least-recently-used entry is evicted
|
|
||||||
{
|
|
||||||
let mut cache = self.cache.write().await;
|
|
||||||
cache.put(hash, (identity.clone(), Instant::now()));
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(Some(identity))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Combined auth state: tries env-var tokens first, then DB-backed tokens.
|
|
||||||
#[derive(Clone)]
|
|
||||||
pub struct CombinedAuthState {
|
|
||||||
/// In-memory tokens from GATEWAY_AUTH_TOKEN.
|
|
||||||
pub env_auth: MultiAuthState,
|
|
||||||
/// DB-backed token authenticator (optional — only when a database is available).
|
|
||||||
pub db_auth: Option<DbAuthenticator>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl From<MultiAuthState> for CombinedAuthState {
|
|
||||||
fn from(env_auth: MultiAuthState) -> Self {
|
|
||||||
Self {
|
|
||||||
env_auth,
|
|
||||||
db_auth: None,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Axum extractor that provides the authenticated user identity.
|
/// Axum extractor that provides the authenticated user identity.
|
||||||
///
|
///
|
||||||
/// Only available on routes behind `auth_middleware`. Extracts the
|
/// Only available on routes behind `auth_middleware`. Extracts the
|
||||||
@@ -250,31 +130,6 @@ where
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Axum extractor that requires the authenticated user to have the `admin` role.
|
|
||||||
///
|
|
||||||
/// Use instead of `AuthenticatedUser` on endpoints that modify system-wide
|
|
||||||
/// state (user management, model selection, extension/skill installation).
|
|
||||||
pub struct AdminUser(pub UserIdentity);
|
|
||||||
|
|
||||||
impl<S> FromRequestParts<S> for AdminUser
|
|
||||||
where
|
|
||||||
S: Send + Sync,
|
|
||||||
{
|
|
||||||
type Rejection = (StatusCode, &'static str);
|
|
||||||
|
|
||||||
async fn from_request_parts(parts: &mut Parts, _state: &S) -> Result<Self, Self::Rejection> {
|
|
||||||
let identity = parts
|
|
||||||
.extensions
|
|
||||||
.get::<UserIdentity>()
|
|
||||||
.cloned()
|
|
||||||
.ok_or((StatusCode::UNAUTHORIZED, "Not authenticated"))?;
|
|
||||||
if identity.role != "admin" {
|
|
||||||
return Err((StatusCode::FORBIDDEN, "Admin role required"));
|
|
||||||
}
|
|
||||||
Ok(AdminUser(identity))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Whether query-string token auth is allowed for this request.
|
/// Whether query-string token auth is allowed for this request.
|
||||||
///
|
///
|
||||||
/// Only GET requests to streaming endpoints may use `?token=xxx`. This
|
/// Only GET requests to streaming endpoints may use `?token=xxx`. This
|
||||||
@@ -311,65 +166,39 @@ fn query_token(request: &Request) -> Option<String> {
|
|||||||
|
|
||||||
/// Auth middleware that validates bearer token from header or query param.
|
/// Auth middleware that validates bearer token from header or query param.
|
||||||
///
|
///
|
||||||
/// Tries env-var tokens first (constant-time, in-memory), then falls back
|
/// SSE connections can't set headers from `EventSource`, so we also accept
|
||||||
/// to DB-backed token lookup if configured. SSE connections can't set
|
/// `?token=xxx` as a query parameter, but only on SSE/WS endpoints.
|
||||||
/// headers from `EventSource`, so we also accept `?token=xxx` as a query
|
|
||||||
/// parameter, but only on SSE/WS endpoints.
|
|
||||||
///
|
///
|
||||||
/// On successful authentication, inserts the matching `UserIdentity` into
|
/// On successful authentication, inserts the matching `UserIdentity` into
|
||||||
/// request extensions for downstream extraction via `AuthenticatedUser`.
|
/// request extensions for downstream extraction via `AuthenticatedUser`.
|
||||||
pub async fn auth_middleware(
|
pub async fn auth_middleware(
|
||||||
State(auth): State<CombinedAuthState>,
|
State(auth): State<MultiAuthState>,
|
||||||
headers: HeaderMap,
|
headers: HeaderMap,
|
||||||
mut request: Request,
|
mut request: Request,
|
||||||
next: Next,
|
next: Next,
|
||||||
) -> Response {
|
) -> Response {
|
||||||
// Extract the candidate token from header or query param.
|
// Try Authorization header first.
|
||||||
let token = extract_token(&headers, &request);
|
// RFC 6750 Section 2.1: auth-scheme comparison is case-insensitive.
|
||||||
|
|
||||||
if let Some(ref tok) = token {
|
|
||||||
// 1. Try env-var tokens first (fast, constant-time, in-memory).
|
|
||||||
if let Some(identity) = auth.env_auth.authenticate(tok) {
|
|
||||||
request.extensions_mut().insert(identity.clone());
|
|
||||||
return next.run(request).await;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 2. Fall back to DB-backed token lookup.
|
|
||||||
if let Some(ref db_auth) = auth.db_auth {
|
|
||||||
match db_auth.authenticate(tok).await {
|
|
||||||
Ok(Some(identity)) => {
|
|
||||||
request.extensions_mut().insert(identity);
|
|
||||||
return next.run(request).await;
|
|
||||||
}
|
|
||||||
Err(()) => {
|
|
||||||
return (StatusCode::SERVICE_UNAVAILABLE, "Database unavailable")
|
|
||||||
.into_response();
|
|
||||||
}
|
|
||||||
Ok(None) => {}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
(StatusCode::UNAUTHORIZED, "Invalid or missing auth token").into_response()
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Extract a bearer token from the Authorization header or query parameter.
|
|
||||||
fn extract_token(headers: &HeaderMap, request: &Request) -> Option<String> {
|
|
||||||
// Try Authorization header first (RFC 6750).
|
|
||||||
if let Some(auth_header) = headers.get("authorization")
|
if let Some(auth_header) = headers.get("authorization")
|
||||||
&& let Ok(value) = auth_header.to_str()
|
&& let Ok(value) = auth_header.to_str()
|
||||||
&& value.len() > 7
|
&& value.len() > 7
|
||||||
&& value[..7].eq_ignore_ascii_case("Bearer ")
|
&& value[..7].eq_ignore_ascii_case("Bearer ")
|
||||||
|
&& let Some(identity) = auth.authenticate(&value[7..])
|
||||||
{
|
{
|
||||||
return Some(value[7..].to_string());
|
request.extensions_mut().insert(identity.clone());
|
||||||
|
return next.run(request).await;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fall back to query parameter for SSE/WS endpoints.
|
// Fall back to query parameter, but only for SSE/WS endpoints.
|
||||||
if allows_query_token_auth(request) {
|
if allows_query_token_auth(&request)
|
||||||
return query_token(request);
|
&& let Some(token) = query_token(&request)
|
||||||
|
&& let Some(identity) = auth.authenticate(&token)
|
||||||
|
{
|
||||||
|
request.extensions_mut().insert(identity.clone());
|
||||||
|
return next.run(request).await;
|
||||||
}
|
}
|
||||||
|
|
||||||
None
|
(StatusCode::UNAUTHORIZED, "Invalid or missing auth token").into_response()
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
@@ -398,7 +227,6 @@ mod tests {
|
|||||||
"tok-alice".to_string(),
|
"tok-alice".to_string(),
|
||||||
UserIdentity {
|
UserIdentity {
|
||||||
user_id: "alice".to_string(),
|
user_id: "alice".to_string(),
|
||||||
role: "admin".to_string(),
|
|
||||||
workspace_read_scopes: Vec::new(),
|
workspace_read_scopes: Vec::new(),
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
@@ -406,7 +234,6 @@ mod tests {
|
|||||||
"tok-bob".to_string(),
|
"tok-bob".to_string(),
|
||||||
UserIdentity {
|
UserIdentity {
|
||||||
user_id: "bob".to_string(),
|
user_id: "bob".to_string(),
|
||||||
role: "admin".to_string(),
|
|
||||||
workspace_read_scopes: Vec::new(),
|
workspace_read_scopes: Vec::new(),
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
@@ -447,10 +274,7 @@ mod tests {
|
|||||||
/// Router with streaming endpoints (query auth allowed) and regular
|
/// Router with streaming endpoints (query auth allowed) and regular
|
||||||
/// endpoints (query auth rejected).
|
/// endpoints (query auth rejected).
|
||||||
fn test_app(token: &str) -> Router {
|
fn test_app(token: &str) -> Router {
|
||||||
let state = CombinedAuthState::from(MultiAuthState::single(
|
let state = MultiAuthState::single(token.to_string(), "test-user".to_string());
|
||||||
token.to_string(),
|
|
||||||
"test-user".to_string(),
|
|
||||||
));
|
|
||||||
Router::new()
|
Router::new()
|
||||||
.route("/api/chat/events", get(dummy_handler))
|
.route("/api/chat/events", get(dummy_handler))
|
||||||
.route("/api/logs/events", get(dummy_handler))
|
.route("/api/logs/events", get(dummy_handler))
|
||||||
@@ -662,7 +486,7 @@ mod tests {
|
|||||||
|
|
||||||
/// Build a multi-user router where each token maps to a distinct identity.
|
/// Build a multi-user router where each token maps to a distinct identity.
|
||||||
fn multi_user_app(tokens: HashMap<String, UserIdentity>) -> Router {
|
fn multi_user_app(tokens: HashMap<String, UserIdentity>) -> Router {
|
||||||
let state = CombinedAuthState::from(MultiAuthState::multi(tokens));
|
let state = MultiAuthState::multi(tokens);
|
||||||
Router::new()
|
Router::new()
|
||||||
.route("/api/chat/events", get(identity_handler))
|
.route("/api/chat/events", get(identity_handler))
|
||||||
.route("/api/chat/send", post(identity_handler))
|
.route("/api/chat/send", post(identity_handler))
|
||||||
@@ -676,7 +500,6 @@ mod tests {
|
|||||||
"tok-alice".to_string(),
|
"tok-alice".to_string(),
|
||||||
UserIdentity {
|
UserIdentity {
|
||||||
user_id: "alice".to_string(),
|
user_id: "alice".to_string(),
|
||||||
role: "admin".to_string(),
|
|
||||||
workspace_read_scopes: vec!["shared".to_string()],
|
workspace_read_scopes: vec!["shared".to_string()],
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
@@ -684,7 +507,6 @@ mod tests {
|
|||||||
"tok-bob".to_string(),
|
"tok-bob".to_string(),
|
||||||
UserIdentity {
|
UserIdentity {
|
||||||
user_id: "bob".to_string(),
|
user_id: "bob".to_string(),
|
||||||
role: "admin".to_string(),
|
|
||||||
workspace_read_scopes: vec!["shared".to_string(), "alice".to_string()],
|
workspace_read_scopes: vec!["shared".to_string(), "alice".to_string()],
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
@@ -821,10 +643,7 @@ mod tests {
|
|||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn test_multi_user_empty_scopes_for_single_user() {
|
async fn test_multi_user_empty_scopes_for_single_user() {
|
||||||
// Single-user mode creates identity with empty workspace_read_scopes.
|
// Single-user mode creates identity with empty workspace_read_scopes.
|
||||||
let state = CombinedAuthState::from(MultiAuthState::single(
|
let state = MultiAuthState::single("tok-only".to_string(), "solo".to_string());
|
||||||
"tok-only".to_string(),
|
|
||||||
"solo".to_string(),
|
|
||||||
));
|
|
||||||
let app = Router::new()
|
let app = Router::new()
|
||||||
.route("/api/scopes", get(scopes_handler))
|
.route("/api/scopes", get(scopes_handler))
|
||||||
.layer(middleware::from_fn_with_state(state, auth_middleware));
|
.layer(middleware::from_fn_with_state(state, auth_middleware));
|
||||||
|
|||||||
@@ -5,10 +5,7 @@
|
|||||||
pub mod jobs;
|
pub mod jobs;
|
||||||
pub mod memory;
|
pub mod memory;
|
||||||
pub mod routines;
|
pub mod routines;
|
||||||
pub mod secrets;
|
|
||||||
pub mod skills;
|
pub mod skills;
|
||||||
pub mod tokens;
|
|
||||||
pub mod users;
|
|
||||||
|
|
||||||
// Modules not yet wired into server.rs router -- suppress dead_code until
|
// Modules not yet wired into server.rs router -- suppress dead_code until
|
||||||
// they replace their inline counterparts.
|
// they replace their inline counterparts.
|
||||||
|
|||||||
@@ -10,7 +10,10 @@ use axum::{
|
|||||||
use serde::Deserialize;
|
use serde::Deserialize;
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
use crate::agent::routine::{Trigger, next_cron_fire};
|
use crate::agent::routine::{
|
||||||
|
RoutineDisplayStatus, RoutineVerificationStatus, Trigger, next_cron_fire,
|
||||||
|
routine_display_status_for_verification, routine_verification_status,
|
||||||
|
};
|
||||||
use crate::channels::web::auth::AuthenticatedUser;
|
use crate::channels::web::auth::AuthenticatedUser;
|
||||||
use crate::channels::web::server::GatewayState;
|
use crate::channels::web::server::GatewayState;
|
||||||
use crate::channels::web::types::*;
|
use crate::channels::web::types::*;
|
||||||
@@ -30,7 +33,18 @@ pub async fn routines_list_handler(
|
|||||||
.await
|
.await
|
||||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||||
|
|
||||||
let items: Vec<RoutineInfo> = routines.iter().map(RoutineInfo::from_routine).collect();
|
let routine_ids: Vec<Uuid> = routines.iter().map(|routine| routine.id).collect();
|
||||||
|
let last_run_statuses = store
|
||||||
|
.batch_get_last_run_status(&routine_ids)
|
||||||
|
.await
|
||||||
|
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||||
|
|
||||||
|
let items: Vec<RoutineInfo> = routines
|
||||||
|
.iter()
|
||||||
|
.map(|routine| {
|
||||||
|
RoutineInfo::from_routine(routine, last_run_statuses.get(&routine.id).copied())
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
Ok(Json(RoutineListResponse { routines: items }))
|
Ok(Json(RoutineListResponse { routines: items }))
|
||||||
}
|
}
|
||||||
@@ -49,13 +63,39 @@ pub async fn routines_summary_handler(
|
|||||||
.await
|
.await
|
||||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||||
|
|
||||||
|
let routine_ids: Vec<Uuid> = routines.iter().map(|routine| routine.id).collect();
|
||||||
|
let last_run_statuses = store
|
||||||
|
.batch_get_last_run_status(&routine_ids)
|
||||||
|
.await
|
||||||
|
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||||
|
|
||||||
let total = routines.len() as u64;
|
let total = routines.len() as u64;
|
||||||
let enabled = routines.iter().filter(|r| r.enabled).count() as u64;
|
let mut enabled = 0u64;
|
||||||
let disabled = total - enabled;
|
let mut disabled = 0u64;
|
||||||
let failing = routines
|
let mut unverified = 0u64;
|
||||||
.iter()
|
let mut failing = 0u64;
|
||||||
.filter(|r| r.consecutive_failures > 0)
|
|
||||||
.count() as u64;
|
for routine in &routines {
|
||||||
|
let verification_status = routine_verification_status(routine);
|
||||||
|
if routine.enabled {
|
||||||
|
enabled += 1;
|
||||||
|
} else {
|
||||||
|
disabled += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
if verification_status == RoutineVerificationStatus::Unverified {
|
||||||
|
unverified += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
if routine_display_status_for_verification(
|
||||||
|
routine,
|
||||||
|
verification_status,
|
||||||
|
last_run_statuses.get(&routine.id).copied(),
|
||||||
|
) == RoutineDisplayStatus::Failing
|
||||||
|
{
|
||||||
|
failing += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
let today_start = chrono::Utc::now()
|
let today_start = chrono::Utc::now()
|
||||||
.date_naive()
|
.date_naive()
|
||||||
@@ -74,6 +114,7 @@ pub async fn routines_summary_handler(
|
|||||||
total,
|
total,
|
||||||
enabled,
|
enabled,
|
||||||
disabled,
|
disabled,
|
||||||
|
unverified,
|
||||||
failing,
|
failing,
|
||||||
runs_today,
|
runs_today,
|
||||||
}))
|
}))
|
||||||
@@ -120,7 +161,7 @@ pub async fn routines_detail_handler(
|
|||||||
job_id: run.job_id,
|
job_id: run.job_id,
|
||||||
})
|
})
|
||||||
.collect();
|
.collect();
|
||||||
let routine_info = RoutineInfo::from_routine(&routine);
|
let routine_info = RoutineInfo::from_routine(&routine, runs.first().map(|run| run.status));
|
||||||
|
|
||||||
Ok(Json(RoutineDetailResponse {
|
Ok(Json(RoutineDetailResponse {
|
||||||
id: routine.id,
|
id: routine.id,
|
||||||
@@ -138,6 +179,8 @@ pub async fn routines_detail_handler(
|
|||||||
next_fire_at: routine.next_fire_at.map(|dt| dt.to_rfc3339()),
|
next_fire_at: routine.next_fire_at.map(|dt| dt.to_rfc3339()),
|
||||||
run_count: routine.run_count,
|
run_count: routine.run_count,
|
||||||
consecutive_failures: routine.consecutive_failures,
|
consecutive_failures: routine.consecutive_failures,
|
||||||
|
status: routine_info.status.clone(),
|
||||||
|
verification_status: routine_info.verification_status.clone(),
|
||||||
created_at: routine.created_at.to_rfc3339(),
|
created_at: routine.created_at.to_rfc3339(),
|
||||||
recent_runs,
|
recent_runs,
|
||||||
}))
|
}))
|
||||||
|
|||||||
@@ -1,134 +0,0 @@
|
|||||||
//! Admin secrets provisioning handlers.
|
|
||||||
//!
|
|
||||||
//! Allows an admin (typically an application backend) to create, list, and
|
|
||||||
//! delete secrets on behalf of individual users so their IronClaw agent can
|
|
||||||
//! call back to external services with per-user credentials.
|
|
||||||
|
|
||||||
use std::sync::Arc;
|
|
||||||
|
|
||||||
use axum::{
|
|
||||||
Json,
|
|
||||||
extract::{Path, State},
|
|
||||||
http::StatusCode,
|
|
||||||
};
|
|
||||||
|
|
||||||
use crate::channels::web::auth::AdminUser;
|
|
||||||
use crate::channels::web::server::GatewayState;
|
|
||||||
use crate::secrets::CreateSecretParams;
|
|
||||||
|
|
||||||
/// PUT /api/admin/users/{user_id}/secrets/{name} — create or update a secret.
|
|
||||||
///
|
|
||||||
/// Upserts: if a secret with the same (user_id, name) already exists it is
|
|
||||||
/// overwritten. The plaintext value is encrypted at rest (AES-256-GCM) and
|
|
||||||
/// never returned by any endpoint.
|
|
||||||
pub async fn secrets_put_handler(
|
|
||||||
State(state): State<Arc<GatewayState>>,
|
|
||||||
AdminUser(_admin): AdminUser,
|
|
||||||
Path((user_id, name)): Path<(String, String)>,
|
|
||||||
Json(body): Json<serde_json::Value>,
|
|
||||||
) -> Result<Json<serde_json::Value>, (StatusCode, String)> {
|
|
||||||
let secrets = state.secrets_store.as_ref().ok_or((
|
|
||||||
StatusCode::SERVICE_UNAVAILABLE,
|
|
||||||
"Secrets store not available".to_string(),
|
|
||||||
))?;
|
|
||||||
|
|
||||||
let value = body
|
|
||||||
.get("value")
|
|
||||||
.and_then(|v| v.as_str())
|
|
||||||
.ok_or((
|
|
||||||
StatusCode::BAD_REQUEST,
|
|
||||||
"Missing required field 'value'".to_string(),
|
|
||||||
))?
|
|
||||||
.to_string();
|
|
||||||
|
|
||||||
let provider = body
|
|
||||||
.get("provider")
|
|
||||||
.and_then(|v| v.as_str())
|
|
||||||
.map(String::from);
|
|
||||||
|
|
||||||
let expires_at = body
|
|
||||||
.get("expires_in_days")
|
|
||||||
.and_then(|v| v.as_u64())
|
|
||||||
.map(|d| d.min(36500))
|
|
||||||
.map(|days| chrono::Utc::now() + chrono::Duration::days(days as i64));
|
|
||||||
|
|
||||||
let mut params = CreateSecretParams::new(name.clone(), value);
|
|
||||||
if let Some(p) = provider {
|
|
||||||
params = params.with_provider(p);
|
|
||||||
}
|
|
||||||
if let Some(exp) = expires_at {
|
|
||||||
params = params.with_expiry(exp);
|
|
||||||
}
|
|
||||||
|
|
||||||
secrets
|
|
||||||
.create(&user_id, params)
|
|
||||||
.await
|
|
||||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
|
||||||
|
|
||||||
Ok(Json(serde_json::json!({
|
|
||||||
"user_id": user_id,
|
|
||||||
"name": name.to_lowercase(),
|
|
||||||
"status": "created",
|
|
||||||
})))
|
|
||||||
}
|
|
||||||
|
|
||||||
/// GET /api/admin/users/{user_id}/secrets — list a user's secrets (names only).
|
|
||||||
///
|
|
||||||
/// Never returns secret values or hashes.
|
|
||||||
pub async fn secrets_list_handler(
|
|
||||||
State(state): State<Arc<GatewayState>>,
|
|
||||||
AdminUser(_admin): AdminUser,
|
|
||||||
Path(user_id): Path<String>,
|
|
||||||
) -> Result<Json<serde_json::Value>, (StatusCode, String)> {
|
|
||||||
let secrets = state.secrets_store.as_ref().ok_or((
|
|
||||||
StatusCode::SERVICE_UNAVAILABLE,
|
|
||||||
"Secrets store not available".to_string(),
|
|
||||||
))?;
|
|
||||||
|
|
||||||
let refs = secrets
|
|
||||||
.list(&user_id)
|
|
||||||
.await
|
|
||||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
|
||||||
|
|
||||||
let secrets_json: Vec<serde_json::Value> = refs
|
|
||||||
.into_iter()
|
|
||||||
.map(|r| {
|
|
||||||
serde_json::json!({
|
|
||||||
"name": r.name,
|
|
||||||
"provider": r.provider,
|
|
||||||
})
|
|
||||||
})
|
|
||||||
.collect();
|
|
||||||
|
|
||||||
Ok(Json(serde_json::json!({
|
|
||||||
"user_id": user_id,
|
|
||||||
"secrets": secrets_json,
|
|
||||||
})))
|
|
||||||
}
|
|
||||||
|
|
||||||
/// DELETE /api/admin/users/{user_id}/secrets/{name} — delete a user's secret.
|
|
||||||
pub async fn secrets_delete_handler(
|
|
||||||
State(state): State<Arc<GatewayState>>,
|
|
||||||
AdminUser(_admin): AdminUser,
|
|
||||||
Path((user_id, name)): Path<(String, String)>,
|
|
||||||
) -> Result<Json<serde_json::Value>, (StatusCode, String)> {
|
|
||||||
let secrets = state.secrets_store.as_ref().ok_or((
|
|
||||||
StatusCode::SERVICE_UNAVAILABLE,
|
|
||||||
"Secrets store not available".to_string(),
|
|
||||||
))?;
|
|
||||||
|
|
||||||
let deleted = secrets
|
|
||||||
.delete(&user_id, &name)
|
|
||||||
.await
|
|
||||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
|
||||||
|
|
||||||
if !deleted {
|
|
||||||
return Err((StatusCode::NOT_FOUND, "Secret not found".to_string()));
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(Json(serde_json::json!({
|
|
||||||
"user_id": user_id,
|
|
||||||
"name": name,
|
|
||||||
"deleted": true,
|
|
||||||
})))
|
|
||||||
}
|
|
||||||
@@ -1,150 +0,0 @@
|
|||||||
//! API token management handlers.
|
|
||||||
|
|
||||||
use std::sync::Arc;
|
|
||||||
|
|
||||||
use axum::{
|
|
||||||
Json,
|
|
||||||
extract::{Path, State},
|
|
||||||
http::StatusCode,
|
|
||||||
};
|
|
||||||
use rand::RngCore;
|
|
||||||
use rand::rngs::OsRng;
|
|
||||||
use uuid::Uuid;
|
|
||||||
|
|
||||||
use crate::channels::web::auth::AuthenticatedUser;
|
|
||||||
use crate::channels::web::server::GatewayState;
|
|
||||||
|
|
||||||
/// POST /api/tokens — create a new API token (returns plaintext ONCE).
|
|
||||||
pub async fn tokens_create_handler(
|
|
||||||
State(state): State<Arc<GatewayState>>,
|
|
||||||
AuthenticatedUser(user): AuthenticatedUser,
|
|
||||||
Json(body): Json<serde_json::Value>,
|
|
||||||
) -> Result<Json<serde_json::Value>, (StatusCode, String)> {
|
|
||||||
let store = state.store.as_ref().ok_or((
|
|
||||||
StatusCode::SERVICE_UNAVAILABLE,
|
|
||||||
"Database not available".to_string(),
|
|
||||||
))?;
|
|
||||||
|
|
||||||
let name = body
|
|
||||||
.get("name")
|
|
||||||
.and_then(|v| v.as_str())
|
|
||||||
.ok_or((
|
|
||||||
StatusCode::BAD_REQUEST,
|
|
||||||
"Missing required field 'name'".to_string(),
|
|
||||||
))?
|
|
||||||
.to_string();
|
|
||||||
|
|
||||||
let expires_in_days: Option<i64> = body
|
|
||||||
.get("expires_in_days")
|
|
||||||
.and_then(|v| v.as_u64())
|
|
||||||
.map(|d| d.min(36500) as i64);
|
|
||||||
|
|
||||||
let expires_at = expires_in_days.map(|days| chrono::Utc::now() + chrono::Duration::days(days));
|
|
||||||
|
|
||||||
// Generate 32 random bytes for the token.
|
|
||||||
// Hash the hex-encoded plaintext (what the user sends as Bearer token),
|
|
||||||
// NOT the raw bytes — must match hash_token() in auth.rs.
|
|
||||||
let mut token_bytes = [0u8; 32];
|
|
||||||
OsRng.fill_bytes(&mut token_bytes);
|
|
||||||
let plaintext_token = hex::encode(token_bytes);
|
|
||||||
let hash = crate::channels::web::auth::hash_token(&plaintext_token);
|
|
||||||
|
|
||||||
// First 8 chars of the hex token as a prefix for identification.
|
|
||||||
let token_prefix = &plaintext_token[..8];
|
|
||||||
|
|
||||||
// Admin users can create tokens for other users via optional "user_id" field.
|
|
||||||
let target_user = body
|
|
||||||
.get("user_id")
|
|
||||||
.and_then(|v| v.as_str())
|
|
||||||
.filter(|_| user.role == "admin")
|
|
||||||
.unwrap_or(&user.user_id);
|
|
||||||
|
|
||||||
// Verify the target user exists to prevent orphan tokens.
|
|
||||||
if target_user != user.user_id {
|
|
||||||
store
|
|
||||||
.get_user(target_user)
|
|
||||||
.await
|
|
||||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?
|
|
||||||
.ok_or((
|
|
||||||
StatusCode::BAD_REQUEST,
|
|
||||||
format!("Target user '{target_user}' not found"),
|
|
||||||
))?;
|
|
||||||
}
|
|
||||||
|
|
||||||
let record = store
|
|
||||||
.create_api_token(target_user, &name, &hash, token_prefix, expires_at)
|
|
||||||
.await
|
|
||||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
|
||||||
|
|
||||||
// Return the plaintext token — this is the ONLY time it is shown.
|
|
||||||
Ok(Json(serde_json::json!({
|
|
||||||
"token": plaintext_token,
|
|
||||||
"id": record.id.to_string(),
|
|
||||||
"name": record.name,
|
|
||||||
"token_prefix": record.token_prefix,
|
|
||||||
"expires_at": record.expires_at.map(|dt| dt.to_rfc3339()),
|
|
||||||
"created_at": record.created_at.to_rfc3339(),
|
|
||||||
})))
|
|
||||||
}
|
|
||||||
|
|
||||||
/// GET /api/tokens — list the current user's tokens (no hashes).
|
|
||||||
pub async fn tokens_list_handler(
|
|
||||||
State(state): State<Arc<GatewayState>>,
|
|
||||||
AuthenticatedUser(user): AuthenticatedUser,
|
|
||||||
) -> Result<Json<serde_json::Value>, (StatusCode, String)> {
|
|
||||||
let store = state.store.as_ref().ok_or((
|
|
||||||
StatusCode::SERVICE_UNAVAILABLE,
|
|
||||||
"Database not available".to_string(),
|
|
||||||
))?;
|
|
||||||
|
|
||||||
let tokens = store
|
|
||||||
.list_api_tokens(&user.user_id)
|
|
||||||
.await
|
|
||||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
|
||||||
|
|
||||||
let tokens_json: Vec<serde_json::Value> = tokens
|
|
||||||
.into_iter()
|
|
||||||
.map(|t| {
|
|
||||||
serde_json::json!({
|
|
||||||
"id": t.id.to_string(),
|
|
||||||
"name": t.name,
|
|
||||||
"token_prefix": t.token_prefix,
|
|
||||||
"expires_at": t.expires_at.map(|dt| dt.to_rfc3339()),
|
|
||||||
"last_used_at": t.last_used_at.map(|dt| dt.to_rfc3339()),
|
|
||||||
"created_at": t.created_at.to_rfc3339(),
|
|
||||||
"revoked_at": t.revoked_at.map(|dt| dt.to_rfc3339()),
|
|
||||||
})
|
|
||||||
})
|
|
||||||
.collect();
|
|
||||||
|
|
||||||
Ok(Json(serde_json::json!({ "tokens": tokens_json })))
|
|
||||||
}
|
|
||||||
|
|
||||||
/// DELETE /api/tokens/{id} — revoke a token.
|
|
||||||
pub async fn tokens_revoke_handler(
|
|
||||||
State(state): State<Arc<GatewayState>>,
|
|
||||||
AuthenticatedUser(user): AuthenticatedUser,
|
|
||||||
Path(id): Path<String>,
|
|
||||||
) -> Result<Json<serde_json::Value>, (StatusCode, String)> {
|
|
||||||
let store = state.store.as_ref().ok_or((
|
|
||||||
StatusCode::SERVICE_UNAVAILABLE,
|
|
||||||
"Database not available".to_string(),
|
|
||||||
))?;
|
|
||||||
|
|
||||||
let token_id = Uuid::parse_str(&id)
|
|
||||||
.map_err(|_| (StatusCode::BAD_REQUEST, "Invalid token ID".to_string()))?;
|
|
||||||
|
|
||||||
let revoked = store
|
|
||||||
.revoke_api_token(token_id, &user.user_id)
|
|
||||||
.await
|
|
||||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
|
||||||
|
|
||||||
if !revoked {
|
|
||||||
return Err((StatusCode::NOT_FOUND, "Token not found".to_string()));
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(Json(serde_json::json!({
|
|
||||||
"status": "revoked",
|
|
||||||
"id": token_id.to_string(),
|
|
||||||
})))
|
|
||||||
}
|
|
||||||
@@ -1,406 +0,0 @@
|
|||||||
//! User management API handlers (admin).
|
|
||||||
|
|
||||||
use std::sync::Arc;
|
|
||||||
|
|
||||||
use axum::{
|
|
||||||
Json,
|
|
||||||
extract::{Path, State},
|
|
||||||
http::StatusCode,
|
|
||||||
};
|
|
||||||
use rand::RngCore;
|
|
||||||
use rand::rngs::OsRng;
|
|
||||||
use uuid::Uuid;
|
|
||||||
|
|
||||||
use crate::channels::web::auth::{AdminUser, AuthenticatedUser};
|
|
||||||
use crate::channels::web::server::GatewayState;
|
|
||||||
use crate::db::UserRecord;
|
|
||||||
|
|
||||||
/// POST /api/admin/users — create a new user.
|
|
||||||
pub async fn users_create_handler(
|
|
||||||
State(state): State<Arc<GatewayState>>,
|
|
||||||
AdminUser(user): AdminUser,
|
|
||||||
Json(body): Json<serde_json::Value>,
|
|
||||||
) -> Result<Json<serde_json::Value>, (StatusCode, String)> {
|
|
||||||
let store = state.store.as_ref().ok_or((
|
|
||||||
StatusCode::SERVICE_UNAVAILABLE,
|
|
||||||
"Database not available".to_string(),
|
|
||||||
))?;
|
|
||||||
|
|
||||||
let display_name = body
|
|
||||||
.get("display_name")
|
|
||||||
.and_then(|v| v.as_str())
|
|
||||||
.ok_or((
|
|
||||||
StatusCode::BAD_REQUEST,
|
|
||||||
"Missing required field 'display_name'".to_string(),
|
|
||||||
))?
|
|
||||||
.to_string();
|
|
||||||
|
|
||||||
let email = body.get("email").and_then(|v| v.as_str()).map(String::from);
|
|
||||||
let role = body
|
|
||||||
.get("role")
|
|
||||||
.and_then(|v| v.as_str())
|
|
||||||
.unwrap_or("member")
|
|
||||||
.to_string();
|
|
||||||
if role != "admin" && role != "member" {
|
|
||||||
return Err((
|
|
||||||
StatusCode::BAD_REQUEST,
|
|
||||||
"role must be 'admin' or 'member'".to_string(),
|
|
||||||
));
|
|
||||||
}
|
|
||||||
|
|
||||||
let user_id = Uuid::new_v4().to_string();
|
|
||||||
|
|
||||||
let now = chrono::Utc::now();
|
|
||||||
let user_record = UserRecord {
|
|
||||||
id: user_id.clone(),
|
|
||||||
email,
|
|
||||||
display_name: display_name.clone(),
|
|
||||||
status: "active".to_string(),
|
|
||||||
role,
|
|
||||||
created_at: now,
|
|
||||||
updated_at: now,
|
|
||||||
last_login_at: None,
|
|
||||||
created_by: match store.get_user(&user.user_id).await {
|
|
||||||
Ok(Some(_)) => Some(user.user_id.clone()),
|
|
||||||
_ => None,
|
|
||||||
},
|
|
||||||
metadata: serde_json::json!({}),
|
|
||||||
};
|
|
||||||
|
|
||||||
store
|
|
||||||
.create_user(&user_record)
|
|
||||||
.await
|
|
||||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
|
||||||
|
|
||||||
// Generate a first API token so the new user can authenticate immediately.
|
|
||||||
// Hash the hex-encoded plaintext (what the user sends as Bearer token),
|
|
||||||
// NOT the raw bytes — must match hash_token() in auth.rs.
|
|
||||||
let mut token_bytes = [0u8; 32];
|
|
||||||
OsRng.fill_bytes(&mut token_bytes);
|
|
||||||
let plaintext_token = hex::encode(token_bytes);
|
|
||||||
let token_hash = crate::channels::web::auth::hash_token(&plaintext_token);
|
|
||||||
let token_prefix = &plaintext_token[..8];
|
|
||||||
|
|
||||||
let _token_record = store
|
|
||||||
.create_api_token(&user_id, "initial", &token_hash, token_prefix, None)
|
|
||||||
.await
|
|
||||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
|
||||||
|
|
||||||
Ok(Json(serde_json::json!({
|
|
||||||
"id": user_record.id,
|
|
||||||
"email": user_record.email,
|
|
||||||
"display_name": user_record.display_name,
|
|
||||||
"status": user_record.status,
|
|
||||||
"role": user_record.role,
|
|
||||||
"token": plaintext_token,
|
|
||||||
"created_at": user_record.created_at.to_rfc3339(),
|
|
||||||
"created_by": user_record.created_by,
|
|
||||||
})))
|
|
||||||
}
|
|
||||||
|
|
||||||
/// GET /api/admin/users — list all users.
|
|
||||||
pub async fn users_list_handler(
|
|
||||||
State(state): State<Arc<GatewayState>>,
|
|
||||||
AdminUser(_user): AdminUser,
|
|
||||||
) -> Result<Json<serde_json::Value>, (StatusCode, String)> {
|
|
||||||
let store = state.store.as_ref().ok_or((
|
|
||||||
StatusCode::SERVICE_UNAVAILABLE,
|
|
||||||
"Database not available".to_string(),
|
|
||||||
))?;
|
|
||||||
|
|
||||||
let users = store
|
|
||||||
.list_users(None)
|
|
||||||
.await
|
|
||||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
|
||||||
|
|
||||||
let users_json: Vec<serde_json::Value> = users
|
|
||||||
.into_iter()
|
|
||||||
.map(|u| {
|
|
||||||
serde_json::json!({
|
|
||||||
"id": u.id,
|
|
||||||
"email": u.email,
|
|
||||||
"display_name": u.display_name,
|
|
||||||
"status": u.status,
|
|
||||||
"role": u.role,
|
|
||||||
"created_at": u.created_at.to_rfc3339(),
|
|
||||||
"updated_at": u.updated_at.to_rfc3339(),
|
|
||||||
"last_login_at": u.last_login_at.map(|dt| dt.to_rfc3339()),
|
|
||||||
"created_by": u.created_by,
|
|
||||||
})
|
|
||||||
})
|
|
||||||
.collect();
|
|
||||||
|
|
||||||
Ok(Json(serde_json::json!({ "users": users_json })))
|
|
||||||
}
|
|
||||||
|
|
||||||
/// GET /api/admin/users/{id} — get a single user.
|
|
||||||
pub async fn users_detail_handler(
|
|
||||||
State(state): State<Arc<GatewayState>>,
|
|
||||||
AdminUser(_user): AdminUser,
|
|
||||||
Path(id): Path<String>,
|
|
||||||
) -> Result<Json<serde_json::Value>, (StatusCode, String)> {
|
|
||||||
let store = state.store.as_ref().ok_or((
|
|
||||||
StatusCode::SERVICE_UNAVAILABLE,
|
|
||||||
"Database not available".to_string(),
|
|
||||||
))?;
|
|
||||||
|
|
||||||
let user_record = store
|
|
||||||
.get_user(&id)
|
|
||||||
.await
|
|
||||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?
|
|
||||||
.ok_or((StatusCode::NOT_FOUND, "User not found".to_string()))?;
|
|
||||||
|
|
||||||
Ok(Json(serde_json::json!({
|
|
||||||
"id": user_record.id,
|
|
||||||
"email": user_record.email,
|
|
||||||
"display_name": user_record.display_name,
|
|
||||||
"status": user_record.status,
|
|
||||||
"role": user_record.role,
|
|
||||||
"created_at": user_record.created_at.to_rfc3339(),
|
|
||||||
"updated_at": user_record.updated_at.to_rfc3339(),
|
|
||||||
"last_login_at": user_record.last_login_at.map(|dt| dt.to_rfc3339()),
|
|
||||||
"created_by": user_record.created_by,
|
|
||||||
"metadata": user_record.metadata,
|
|
||||||
})))
|
|
||||||
}
|
|
||||||
|
|
||||||
/// PATCH /api/admin/users/{id} — update a user's profile.
|
|
||||||
pub async fn users_update_handler(
|
|
||||||
State(state): State<Arc<GatewayState>>,
|
|
||||||
AdminUser(_user): AdminUser,
|
|
||||||
Path(id): Path<String>,
|
|
||||||
Json(body): Json<serde_json::Value>,
|
|
||||||
) -> Result<Json<serde_json::Value>, (StatusCode, String)> {
|
|
||||||
let store = state.store.as_ref().ok_or((
|
|
||||||
StatusCode::SERVICE_UNAVAILABLE,
|
|
||||||
"Database not available".to_string(),
|
|
||||||
))?;
|
|
||||||
|
|
||||||
// Verify the user exists.
|
|
||||||
let existing = store
|
|
||||||
.get_user(&id)
|
|
||||||
.await
|
|
||||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?
|
|
||||||
.ok_or((StatusCode::NOT_FOUND, "User not found".to_string()))?;
|
|
||||||
|
|
||||||
let display_name = body
|
|
||||||
.get("display_name")
|
|
||||||
.and_then(|v| v.as_str())
|
|
||||||
.unwrap_or(&existing.display_name);
|
|
||||||
|
|
||||||
let metadata = body.get("metadata").unwrap_or(&existing.metadata);
|
|
||||||
|
|
||||||
store
|
|
||||||
.update_user_profile(&id, display_name, metadata)
|
|
||||||
.await
|
|
||||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
|
||||||
|
|
||||||
// Re-fetch the updated record to return consistent data.
|
|
||||||
let updated = store
|
|
||||||
.get_user(&id)
|
|
||||||
.await
|
|
||||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?
|
|
||||||
.ok_or((StatusCode::NOT_FOUND, "User not found".to_string()))?;
|
|
||||||
|
|
||||||
Ok(Json(serde_json::json!({
|
|
||||||
"id": updated.id,
|
|
||||||
"email": updated.email,
|
|
||||||
"display_name": updated.display_name,
|
|
||||||
"status": updated.status,
|
|
||||||
"role": updated.role,
|
|
||||||
"created_at": updated.created_at.to_rfc3339(),
|
|
||||||
"updated_at": updated.updated_at.to_rfc3339(),
|
|
||||||
"metadata": updated.metadata,
|
|
||||||
})))
|
|
||||||
}
|
|
||||||
|
|
||||||
/// POST /api/admin/users/{id}/suspend — suspend a user.
|
|
||||||
pub async fn users_suspend_handler(
|
|
||||||
State(state): State<Arc<GatewayState>>,
|
|
||||||
AdminUser(_user): AdminUser,
|
|
||||||
Path(id): Path<String>,
|
|
||||||
) -> Result<Json<serde_json::Value>, (StatusCode, String)> {
|
|
||||||
let store = state.store.as_ref().ok_or((
|
|
||||||
StatusCode::SERVICE_UNAVAILABLE,
|
|
||||||
"Database not available".to_string(),
|
|
||||||
))?;
|
|
||||||
|
|
||||||
// Verify the user exists.
|
|
||||||
store
|
|
||||||
.get_user(&id)
|
|
||||||
.await
|
|
||||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?
|
|
||||||
.ok_or((StatusCode::NOT_FOUND, "User not found".to_string()))?;
|
|
||||||
|
|
||||||
store
|
|
||||||
.update_user_status(&id, "suspended")
|
|
||||||
.await
|
|
||||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
|
||||||
|
|
||||||
Ok(Json(serde_json::json!({
|
|
||||||
"id": id,
|
|
||||||
"status": "suspended",
|
|
||||||
})))
|
|
||||||
}
|
|
||||||
|
|
||||||
/// POST /api/admin/users/{id}/activate — activate a user.
|
|
||||||
pub async fn users_activate_handler(
|
|
||||||
State(state): State<Arc<GatewayState>>,
|
|
||||||
AdminUser(_user): AdminUser,
|
|
||||||
Path(id): Path<String>,
|
|
||||||
) -> Result<Json<serde_json::Value>, (StatusCode, String)> {
|
|
||||||
let store = state.store.as_ref().ok_or((
|
|
||||||
StatusCode::SERVICE_UNAVAILABLE,
|
|
||||||
"Database not available".to_string(),
|
|
||||||
))?;
|
|
||||||
|
|
||||||
// Verify the user exists.
|
|
||||||
store
|
|
||||||
.get_user(&id)
|
|
||||||
.await
|
|
||||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?
|
|
||||||
.ok_or((StatusCode::NOT_FOUND, "User not found".to_string()))?;
|
|
||||||
|
|
||||||
store
|
|
||||||
.update_user_status(&id, "active")
|
|
||||||
.await
|
|
||||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
|
||||||
|
|
||||||
Ok(Json(serde_json::json!({
|
|
||||||
"id": id,
|
|
||||||
"status": "active",
|
|
||||||
})))
|
|
||||||
}
|
|
||||||
|
|
||||||
/// DELETE /api/admin/users/{id} — delete a user and all their data.
|
|
||||||
pub async fn users_delete_handler(
|
|
||||||
State(state): State<Arc<GatewayState>>,
|
|
||||||
AdminUser(_user): AdminUser,
|
|
||||||
Path(id): Path<String>,
|
|
||||||
) -> Result<Json<serde_json::Value>, (StatusCode, String)> {
|
|
||||||
let store = state.store.as_ref().ok_or((
|
|
||||||
StatusCode::SERVICE_UNAVAILABLE,
|
|
||||||
"Database not available".to_string(),
|
|
||||||
))?;
|
|
||||||
|
|
||||||
let deleted = store
|
|
||||||
.delete_user(&id)
|
|
||||||
.await
|
|
||||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
|
||||||
|
|
||||||
if !deleted {
|
|
||||||
return Err((StatusCode::NOT_FOUND, "User not found".to_string()));
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(Json(serde_json::json!({
|
|
||||||
"id": id,
|
|
||||||
"deleted": true,
|
|
||||||
})))
|
|
||||||
}
|
|
||||||
|
|
||||||
/// GET /api/profile — get the authenticated user's own profile.
|
|
||||||
pub async fn profile_get_handler(
|
|
||||||
State(state): State<Arc<GatewayState>>,
|
|
||||||
AuthenticatedUser(user): AuthenticatedUser,
|
|
||||||
) -> Result<Json<serde_json::Value>, (StatusCode, String)> {
|
|
||||||
let store = state.store.as_ref().ok_or((
|
|
||||||
StatusCode::SERVICE_UNAVAILABLE,
|
|
||||||
"Database not available".to_string(),
|
|
||||||
))?;
|
|
||||||
|
|
||||||
let record = store
|
|
||||||
.get_user(&user.user_id)
|
|
||||||
.await
|
|
||||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?
|
|
||||||
.ok_or((StatusCode::NOT_FOUND, "User not found".to_string()))?;
|
|
||||||
|
|
||||||
Ok(Json(serde_json::json!({
|
|
||||||
"id": record.id,
|
|
||||||
"email": record.email,
|
|
||||||
"display_name": record.display_name,
|
|
||||||
"status": record.status,
|
|
||||||
"role": record.role,
|
|
||||||
"created_at": record.created_at.to_rfc3339(),
|
|
||||||
"last_login_at": record.last_login_at.map(|dt| dt.to_rfc3339()),
|
|
||||||
})))
|
|
||||||
}
|
|
||||||
|
|
||||||
/// PATCH /api/profile — update the authenticated user's own profile.
|
|
||||||
pub async fn profile_update_handler(
|
|
||||||
State(state): State<Arc<GatewayState>>,
|
|
||||||
AuthenticatedUser(user): AuthenticatedUser,
|
|
||||||
Json(body): Json<serde_json::Value>,
|
|
||||||
) -> Result<Json<serde_json::Value>, (StatusCode, String)> {
|
|
||||||
let store = state.store.as_ref().ok_or((
|
|
||||||
StatusCode::SERVICE_UNAVAILABLE,
|
|
||||||
"Database not available".to_string(),
|
|
||||||
))?;
|
|
||||||
|
|
||||||
let current = store
|
|
||||||
.get_user(&user.user_id)
|
|
||||||
.await
|
|
||||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?
|
|
||||||
.ok_or((StatusCode::NOT_FOUND, "User not found".to_string()))?;
|
|
||||||
|
|
||||||
let display_name = body
|
|
||||||
.get("display_name")
|
|
||||||
.and_then(|v| v.as_str())
|
|
||||||
.unwrap_or(¤t.display_name);
|
|
||||||
let metadata = body.get("metadata").unwrap_or(¤t.metadata);
|
|
||||||
|
|
||||||
store
|
|
||||||
.update_user_profile(&user.user_id, display_name, metadata)
|
|
||||||
.await
|
|
||||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
|
||||||
|
|
||||||
Ok(Json(serde_json::json!({
|
|
||||||
"id": user.user_id,
|
|
||||||
"display_name": display_name,
|
|
||||||
"updated": true,
|
|
||||||
})))
|
|
||||||
}
|
|
||||||
|
|
||||||
/// GET /api/admin/usage — per-user LLM usage stats.
|
|
||||||
pub async fn usage_stats_handler(
|
|
||||||
State(state): State<Arc<GatewayState>>,
|
|
||||||
AdminUser(_user): AdminUser,
|
|
||||||
axum::extract::Query(params): axum::extract::Query<std::collections::HashMap<String, String>>,
|
|
||||||
) -> Result<Json<serde_json::Value>, (StatusCode, String)> {
|
|
||||||
let store = state.store.as_ref().ok_or((
|
|
||||||
StatusCode::SERVICE_UNAVAILABLE,
|
|
||||||
"Database not available".to_string(),
|
|
||||||
))?;
|
|
||||||
|
|
||||||
let user_id = params.get("user_id").map(|s| s.as_str());
|
|
||||||
let period = params.get("period").map(|s| s.as_str()).unwrap_or("day");
|
|
||||||
let since = match period {
|
|
||||||
"week" => chrono::Utc::now() - chrono::Duration::days(7),
|
|
||||||
"month" => chrono::Utc::now() - chrono::Duration::days(30),
|
|
||||||
_ => chrono::Utc::now() - chrono::Duration::days(1),
|
|
||||||
};
|
|
||||||
|
|
||||||
let stats = store
|
|
||||||
.user_usage_stats(user_id, since)
|
|
||||||
.await
|
|
||||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
|
||||||
|
|
||||||
let entries: Vec<serde_json::Value> = stats
|
|
||||||
.iter()
|
|
||||||
.map(|s| {
|
|
||||||
serde_json::json!({
|
|
||||||
"user_id": s.user_id,
|
|
||||||
"model": s.model,
|
|
||||||
"call_count": s.call_count,
|
|
||||||
"input_tokens": s.input_tokens,
|
|
||||||
"output_tokens": s.output_tokens,
|
|
||||||
"total_cost": s.total_cost.to_string(),
|
|
||||||
})
|
|
||||||
})
|
|
||||||
.collect();
|
|
||||||
|
|
||||||
Ok(Json(serde_json::json!({
|
|
||||||
"period": period,
|
|
||||||
"since": since.to_rfc3339(),
|
|
||||||
"usage": entries,
|
|
||||||
})))
|
|
||||||
}
|
|
||||||
+65
-28
@@ -18,7 +18,6 @@ pub mod auth;
|
|||||||
pub(crate) mod handlers;
|
pub(crate) mod handlers;
|
||||||
pub mod log_layer;
|
pub mod log_layer;
|
||||||
pub mod openai_compat;
|
pub mod openai_compat;
|
||||||
pub mod responses_api;
|
|
||||||
pub mod server;
|
pub mod server;
|
||||||
pub mod sse;
|
pub mod sse;
|
||||||
pub mod types;
|
pub mod types;
|
||||||
@@ -56,7 +55,7 @@ use crate::workspace::Workspace;
|
|||||||
|
|
||||||
use self::log_layer::{LogBroadcaster, LogLevelHandle};
|
use self::log_layer::{LogBroadcaster, LogLevelHandle};
|
||||||
|
|
||||||
use self::auth::{CombinedAuthState, DbAuthenticator, MultiAuthState};
|
use self::auth::MultiAuthState;
|
||||||
use self::server::GatewayState;
|
use self::server::GatewayState;
|
||||||
use self::sse::SseManager;
|
use self::sse::SseManager;
|
||||||
use self::types::AppEvent;
|
use self::types::AppEvent;
|
||||||
@@ -65,8 +64,8 @@ use self::types::AppEvent;
|
|||||||
pub struct GatewayChannel {
|
pub struct GatewayChannel {
|
||||||
config: GatewayConfig,
|
config: GatewayConfig,
|
||||||
state: Arc<GatewayState>,
|
state: Arc<GatewayState>,
|
||||||
/// Combined auth state: env-var tokens + optional DB-backed tokens.
|
/// Multi-user auth state (replaces bare auth_token).
|
||||||
auth: CombinedAuthState,
|
auth: MultiAuthState,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl GatewayChannel {
|
impl GatewayChannel {
|
||||||
@@ -74,7 +73,7 @@ impl GatewayChannel {
|
|||||||
///
|
///
|
||||||
/// If no auth token is configured, generates a random one and prints it.
|
/// If no auth token is configured, generates a random one and prints it.
|
||||||
/// Builds a single-user `MultiAuthState` from the config.
|
/// Builds a single-user `MultiAuthState` from the config.
|
||||||
pub fn new(config: GatewayConfig, owner_id: String) -> Self {
|
pub fn new(config: GatewayConfig) -> Self {
|
||||||
let auth_token = config.auth_token.clone().unwrap_or_else(|| {
|
let auth_token = config.auth_token.clone().unwrap_or_else(|| {
|
||||||
use rand::RngCore;
|
use rand::RngCore;
|
||||||
use rand::rngs::OsRng;
|
use rand::rngs::OsRng;
|
||||||
@@ -83,10 +82,7 @@ impl GatewayChannel {
|
|||||||
bytes.iter().map(|b| format!("{b:02x}")).collect()
|
bytes.iter().map(|b| format!("{b:02x}")).collect()
|
||||||
});
|
});
|
||||||
|
|
||||||
let auth = CombinedAuthState {
|
let auth = MultiAuthState::single(auth_token, config.user_id.clone());
|
||||||
env_auth: MultiAuthState::single(auth_token, owner_id.clone()),
|
|
||||||
db_auth: None,
|
|
||||||
};
|
|
||||||
|
|
||||||
let state = Arc::new(GatewayState {
|
let state = Arc::new(GatewayState {
|
||||||
msg_tx: tokio::sync::RwLock::new(None),
|
msg_tx: tokio::sync::RwLock::new(None),
|
||||||
@@ -102,7 +98,8 @@ impl GatewayChannel {
|
|||||||
job_manager: None,
|
job_manager: None,
|
||||||
prompt_queue: None,
|
prompt_queue: None,
|
||||||
scheduler: None,
|
scheduler: None,
|
||||||
owner_id,
|
owner_id: config.user_id.clone(),
|
||||||
|
default_sender_id: config.user_id.clone(),
|
||||||
shutdown_tx: tokio::sync::RwLock::new(None),
|
shutdown_tx: tokio::sync::RwLock::new(None),
|
||||||
ws_tracker: Some(Arc::new(ws::WsConnectionTracker::new())),
|
ws_tracker: Some(Arc::new(ws::WsConnectionTracker::new())),
|
||||||
llm_provider: None,
|
llm_provider: None,
|
||||||
@@ -116,7 +113,62 @@ impl GatewayChannel {
|
|||||||
routine_engine: Arc::new(tokio::sync::RwLock::new(None)),
|
routine_engine: Arc::new(tokio::sync::RwLock::new(None)),
|
||||||
startup_time: std::time::Instant::now(),
|
startup_time: std::time::Instant::now(),
|
||||||
active_config: server::ActiveConfigSnapshot::default(),
|
active_config: server::ActiveConfigSnapshot::default(),
|
||||||
secrets_store: None,
|
});
|
||||||
|
|
||||||
|
Self {
|
||||||
|
config,
|
||||||
|
state,
|
||||||
|
auth,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Rebind the single-user auth identity to the durable owner scope while
|
||||||
|
/// preserving the configured gateway sender/routing identity.
|
||||||
|
pub fn with_owner_scope(mut self, owner_id: impl Into<String>) -> Self {
|
||||||
|
let owner_id = owner_id.into();
|
||||||
|
let single_user_token = if self.config.user_tokens.is_none() {
|
||||||
|
self.auth.first_token().map(ToOwned::to_owned)
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
};
|
||||||
|
if let Some(token) = single_user_token {
|
||||||
|
self.auth = MultiAuthState::single(token, owner_id.clone());
|
||||||
|
}
|
||||||
|
self.rebuild_state(|s| s.owner_id = owner_id);
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Create a gateway channel with a pre-built multi-user auth state.
|
||||||
|
pub fn new_multi_auth(config: GatewayConfig, auth: MultiAuthState) -> Self {
|
||||||
|
let state = Arc::new(GatewayState {
|
||||||
|
msg_tx: tokio::sync::RwLock::new(None),
|
||||||
|
sse: Arc::new(SseManager::new()),
|
||||||
|
workspace: None,
|
||||||
|
workspace_pool: None,
|
||||||
|
session_manager: None,
|
||||||
|
log_broadcaster: None,
|
||||||
|
log_level_handle: None,
|
||||||
|
extension_manager: None,
|
||||||
|
tool_registry: None,
|
||||||
|
store: None,
|
||||||
|
job_manager: None,
|
||||||
|
prompt_queue: None,
|
||||||
|
scheduler: None,
|
||||||
|
owner_id: config.user_id.clone(),
|
||||||
|
default_sender_id: config.user_id.clone(),
|
||||||
|
shutdown_tx: tokio::sync::RwLock::new(None),
|
||||||
|
ws_tracker: Some(Arc::new(ws::WsConnectionTracker::new())),
|
||||||
|
llm_provider: None,
|
||||||
|
skill_registry: None,
|
||||||
|
skill_catalog: None,
|
||||||
|
chat_rate_limiter: server::PerUserRateLimiter::new(30, 60),
|
||||||
|
oauth_rate_limiter: server::RateLimiter::new(10, 60),
|
||||||
|
registry_entries: Vec::new(),
|
||||||
|
cost_guard: None,
|
||||||
|
routine_engine: Arc::new(tokio::sync::RwLock::new(None)),
|
||||||
|
startup_time: std::time::Instant::now(),
|
||||||
|
webhook_rate_limiter: server::RateLimiter::new(10, 60),
|
||||||
|
active_config: server::ActiveConfigSnapshot::default(),
|
||||||
});
|
});
|
||||||
|
|
||||||
Self {
|
Self {
|
||||||
@@ -144,6 +196,7 @@ impl GatewayChannel {
|
|||||||
prompt_queue: self.state.prompt_queue.clone(),
|
prompt_queue: self.state.prompt_queue.clone(),
|
||||||
scheduler: self.state.scheduler.clone(),
|
scheduler: self.state.scheduler.clone(),
|
||||||
owner_id: self.state.owner_id.clone(),
|
owner_id: self.state.owner_id.clone(),
|
||||||
|
default_sender_id: self.state.default_sender_id.clone(),
|
||||||
shutdown_tx: tokio::sync::RwLock::new(None),
|
shutdown_tx: tokio::sync::RwLock::new(None),
|
||||||
ws_tracker: self.state.ws_tracker.clone(),
|
ws_tracker: self.state.ws_tracker.clone(),
|
||||||
llm_provider: self.state.llm_provider.clone(),
|
llm_provider: self.state.llm_provider.clone(),
|
||||||
@@ -157,7 +210,6 @@ impl GatewayChannel {
|
|||||||
routine_engine: Arc::clone(&self.state.routine_engine),
|
routine_engine: Arc::clone(&self.state.routine_engine),
|
||||||
startup_time: self.state.startup_time,
|
startup_time: self.state.startup_time,
|
||||||
active_config: self.state.active_config.clone(),
|
active_config: self.state.active_config.clone(),
|
||||||
secrets_store: self.state.secrets_store.clone(),
|
|
||||||
};
|
};
|
||||||
mutate(&mut new_state);
|
mutate(&mut new_state);
|
||||||
self.state = Arc::new(new_state);
|
self.state = Arc::new(new_state);
|
||||||
@@ -205,12 +257,6 @@ impl GatewayChannel {
|
|||||||
self
|
self
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Enable DB-backed token authentication alongside env-var tokens.
|
|
||||||
pub fn with_db_auth(mut self, store: Arc<dyn Database>) -> Self {
|
|
||||||
self.auth.db_auth = Some(DbAuthenticator::new(store));
|
|
||||||
self
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Inject the container job manager for sandbox operations.
|
/// Inject the container job manager for sandbox operations.
|
||||||
pub fn with_job_manager(mut self, jm: Arc<ContainerJobManager>) -> Self {
|
pub fn with_job_manager(mut self, jm: Arc<ContainerJobManager>) -> Self {
|
||||||
self.rebuild_state(|s| s.job_manager = Some(jm));
|
self.rebuild_state(|s| s.job_manager = Some(jm));
|
||||||
@@ -281,15 +327,6 @@ impl GatewayChannel {
|
|||||||
self
|
self
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Inject the secrets store for admin secret provisioning.
|
|
||||||
pub fn with_secrets_store(
|
|
||||||
mut self,
|
|
||||||
store: Arc<dyn crate::secrets::SecretsStore + Send + Sync>,
|
|
||||||
) -> Self {
|
|
||||||
self.rebuild_state(|s| s.secrets_store = Some(store));
|
|
||||||
self
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Inject the per-user workspace pool for multi-user mode.
|
/// Inject the per-user workspace pool for multi-user mode.
|
||||||
pub fn with_workspace_pool(mut self, pool: Arc<server::WorkspacePool>) -> Self {
|
pub fn with_workspace_pool(mut self, pool: Arc<server::WorkspacePool>) -> Self {
|
||||||
self.rebuild_state(|s| s.workspace_pool = Some(pool));
|
self.rebuild_state(|s| s.workspace_pool = Some(pool));
|
||||||
@@ -298,7 +335,7 @@ impl GatewayChannel {
|
|||||||
|
|
||||||
/// Get the first auth token (for printing to console on startup).
|
/// Get the first auth token (for printing to console on startup).
|
||||||
pub fn auth_token(&self) -> &str {
|
pub fn auth_token(&self) -> &str {
|
||||||
self.auth.env_auth.first_token().unwrap_or("")
|
self.auth.first_token().unwrap_or("")
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Get a reference to the shared gateway state (for the agent to push SSE events).
|
/// Get a reference to the shared gateway state (for the agent to push SSE events).
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
+13
-92
@@ -16,7 +16,7 @@ use axum::{
|
|||||||
IntoResponse,
|
IntoResponse,
|
||||||
sse::{Event, KeepAlive, Sse},
|
sse::{Event, KeepAlive, Sse},
|
||||||
},
|
},
|
||||||
routing::{get, post, put},
|
routing::{get, post},
|
||||||
};
|
};
|
||||||
use serde::Deserialize;
|
use serde::Deserialize;
|
||||||
use sha2::{Digest, Sha256};
|
use sha2::{Digest, Sha256};
|
||||||
@@ -31,7 +31,7 @@ use crate::bootstrap::ironclaw_base_dir;
|
|||||||
use crate::channels::IncomingMessage;
|
use crate::channels::IncomingMessage;
|
||||||
use crate::channels::relay::DEFAULT_RELAY_NAME;
|
use crate::channels::relay::DEFAULT_RELAY_NAME;
|
||||||
use crate::channels::web::auth::{
|
use crate::channels::web::auth::{
|
||||||
AuthenticatedUser, CombinedAuthState, UserIdentity, auth_middleware,
|
AuthenticatedUser, MultiAuthState, UserIdentity, auth_middleware,
|
||||||
};
|
};
|
||||||
use crate::channels::web::handlers::jobs::{
|
use crate::channels::web::handlers::jobs::{
|
||||||
job_files_list_handler, job_files_read_handler, jobs_cancel_handler, jobs_detail_handler,
|
job_files_list_handler, job_files_read_handler, jobs_cancel_handler, jobs_detail_handler,
|
||||||
@@ -347,6 +347,8 @@ pub struct GatewayState {
|
|||||||
pub prompt_queue: Option<PromptQueue>,
|
pub prompt_queue: Option<PromptQueue>,
|
||||||
/// Durable owner scope for persistence and unauthenticated callback flows.
|
/// Durable owner scope for persistence and unauthenticated callback flows.
|
||||||
pub owner_id: String,
|
pub owner_id: String,
|
||||||
|
/// Default sender/routing identity for gateway-originated messages.
|
||||||
|
pub default_sender_id: String,
|
||||||
/// Shutdown signal sender.
|
/// Shutdown signal sender.
|
||||||
pub shutdown_tx: tokio::sync::RwLock<Option<oneshot::Sender<()>>>,
|
pub shutdown_tx: tokio::sync::RwLock<Option<oneshot::Sender<()>>>,
|
||||||
/// WebSocket connection tracker.
|
/// WebSocket connection tracker.
|
||||||
@@ -376,8 +378,6 @@ pub struct GatewayState {
|
|||||||
pub startup_time: std::time::Instant,
|
pub startup_time: std::time::Instant,
|
||||||
/// Snapshot of active (resolved) configuration for the frontend.
|
/// Snapshot of active (resolved) configuration for the frontend.
|
||||||
pub active_config: ActiveConfigSnapshot,
|
pub active_config: ActiveConfigSnapshot,
|
||||||
/// Secrets store for admin secret provisioning.
|
|
||||||
pub secrets_store: Option<Arc<dyn crate::secrets::SecretsStore + Send + Sync>>,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Start the gateway HTTP server.
|
/// Start the gateway HTTP server.
|
||||||
@@ -386,7 +386,7 @@ pub struct GatewayState {
|
|||||||
pub async fn start_server(
|
pub async fn start_server(
|
||||||
addr: SocketAddr,
|
addr: SocketAddr,
|
||||||
state: Arc<GatewayState>,
|
state: Arc<GatewayState>,
|
||||||
auth: CombinedAuthState,
|
auth: MultiAuthState,
|
||||||
) -> Result<SocketAddr, crate::error::ChannelError> {
|
) -> Result<SocketAddr, crate::error::ChannelError> {
|
||||||
let listener = tokio::net::TcpListener::bind(addr).await.map_err(|e| {
|
let listener = tokio::net::TcpListener::bind(addr).await.map_err(|e| {
|
||||||
crate::error::ChannelError::StartupFailed {
|
crate::error::ChannelError::StartupFailed {
|
||||||
@@ -512,57 +512,6 @@ pub async fn start_server(
|
|||||||
"/api/settings/{key}",
|
"/api/settings/{key}",
|
||||||
axum::routing::delete(settings_delete_handler),
|
axum::routing::delete(settings_delete_handler),
|
||||||
)
|
)
|
||||||
// User management (admin)
|
|
||||||
.route(
|
|
||||||
"/api/admin/users",
|
|
||||||
get(super::handlers::users::users_list_handler)
|
|
||||||
.post(super::handlers::users::users_create_handler),
|
|
||||||
)
|
|
||||||
.route(
|
|
||||||
"/api/admin/users/{id}",
|
|
||||||
get(super::handlers::users::users_detail_handler)
|
|
||||||
.patch(super::handlers::users::users_update_handler)
|
|
||||||
.delete(super::handlers::users::users_delete_handler),
|
|
||||||
)
|
|
||||||
.route(
|
|
||||||
"/api/admin/users/{id}/suspend",
|
|
||||||
post(super::handlers::users::users_suspend_handler),
|
|
||||||
)
|
|
||||||
.route(
|
|
||||||
"/api/admin/users/{id}/activate",
|
|
||||||
post(super::handlers::users::users_activate_handler),
|
|
||||||
)
|
|
||||||
// Admin secrets provisioning (per-user)
|
|
||||||
.route(
|
|
||||||
"/api/admin/users/{user_id}/secrets",
|
|
||||||
get(super::handlers::secrets::secrets_list_handler),
|
|
||||||
)
|
|
||||||
.route(
|
|
||||||
"/api/admin/users/{user_id}/secrets/{name}",
|
|
||||||
put(super::handlers::secrets::secrets_put_handler)
|
|
||||||
.delete(super::handlers::secrets::secrets_delete_handler),
|
|
||||||
)
|
|
||||||
// Usage reporting (admin)
|
|
||||||
.route(
|
|
||||||
"/api/admin/usage",
|
|
||||||
get(super::handlers::users::usage_stats_handler),
|
|
||||||
)
|
|
||||||
// User self-service profile
|
|
||||||
.route(
|
|
||||||
"/api/profile",
|
|
||||||
get(super::handlers::users::profile_get_handler)
|
|
||||||
.patch(super::handlers::users::profile_update_handler),
|
|
||||||
)
|
|
||||||
// Token management
|
|
||||||
.route(
|
|
||||||
"/api/tokens",
|
|
||||||
get(super::handlers::tokens::tokens_list_handler)
|
|
||||||
.post(super::handlers::tokens::tokens_create_handler),
|
|
||||||
)
|
|
||||||
.route(
|
|
||||||
"/api/tokens/{id}",
|
|
||||||
axum::routing::delete(super::handlers::tokens::tokens_revoke_handler),
|
|
||||||
)
|
|
||||||
// Gateway control plane
|
// Gateway control plane
|
||||||
.route("/api/gateway/status", get(gateway_status_handler))
|
.route("/api/gateway/status", get(gateway_status_handler))
|
||||||
// OpenAI-compatible API
|
// OpenAI-compatible API
|
||||||
@@ -571,15 +520,6 @@ pub async fn start_server(
|
|||||||
post(super::openai_compat::chat_completions_handler),
|
post(super::openai_compat::chat_completions_handler),
|
||||||
)
|
)
|
||||||
.route("/v1/models", get(super::openai_compat::models_handler))
|
.route("/v1/models", get(super::openai_compat::models_handler))
|
||||||
// OpenAI Responses API (routes through the full agent loop)
|
|
||||||
.route(
|
|
||||||
"/v1/responses",
|
|
||||||
post(super::responses_api::create_response_handler),
|
|
||||||
)
|
|
||||||
.route(
|
|
||||||
"/v1/responses/{id}",
|
|
||||||
get(super::responses_api::get_response_handler),
|
|
||||||
)
|
|
||||||
.route_layer(middleware::from_fn_with_state(
|
.route_layer(middleware::from_fn_with_state(
|
||||||
auth_state.clone(),
|
auth_state.clone(),
|
||||||
auth_middleware,
|
auth_middleware,
|
||||||
@@ -622,7 +562,6 @@ pub async fn start_server(
|
|||||||
axum::http::Method::GET,
|
axum::http::Method::GET,
|
||||||
axum::http::Method::POST,
|
axum::http::Method::POST,
|
||||||
axum::http::Method::PUT,
|
axum::http::Method::PUT,
|
||||||
axum::http::Method::PATCH,
|
|
||||||
axum::http::Method::DELETE,
|
axum::http::Method::DELETE,
|
||||||
])
|
])
|
||||||
.allow_headers(AllowHeaders::list([
|
.allow_headers(AllowHeaders::list([
|
||||||
@@ -637,25 +576,6 @@ pub async fn start_server(
|
|||||||
.merge(projects)
|
.merge(projects)
|
||||||
.merge(protected)
|
.merge(protected)
|
||||||
.layer(DefaultBodyLimit::max(10 * 1024 * 1024)) // 10 MB max request body (image uploads)
|
.layer(DefaultBodyLimit::max(10 * 1024 * 1024)) // 10 MB max request body (image uploads)
|
||||||
.layer(tower_http::catch_panic::CatchPanicLayer::custom(
|
|
||||||
|panic_info: Box<dyn std::any::Any + Send + 'static>| {
|
|
||||||
let detail = if let Some(s) = panic_info.downcast_ref::<String>() {
|
|
||||||
s.clone()
|
|
||||||
} else if let Some(s) = panic_info.downcast_ref::<&str>() {
|
|
||||||
(*s).to_string()
|
|
||||||
} else {
|
|
||||||
"unknown panic".to_string()
|
|
||||||
};
|
|
||||||
tracing::error!("Handler panicked: {}", detail);
|
|
||||||
axum::http::Response::builder()
|
|
||||||
.status(axum::http::StatusCode::INTERNAL_SERVER_ERROR)
|
|
||||||
.header("content-type", "text/plain")
|
|
||||||
.body(axum::body::Body::from("Internal Server Error"))
|
|
||||||
.unwrap_or_else(|_| {
|
|
||||||
axum::http::Response::new(axum::body::Body::from("Internal Server Error"))
|
|
||||||
})
|
|
||||||
},
|
|
||||||
))
|
|
||||||
.layer(cors)
|
.layer(cors)
|
||||||
.layer(SetResponseHeaderLayer::if_not_present(
|
.layer(SetResponseHeaderLayer::if_not_present(
|
||||||
header::X_CONTENT_TYPE_OPTIONS,
|
header::X_CONTENT_TYPE_OPTIONS,
|
||||||
@@ -1383,6 +1303,9 @@ async fn chat_send_handler(
|
|||||||
}
|
}
|
||||||
|
|
||||||
let mut msg = IncomingMessage::new("gateway", &user.user_id, &req.content);
|
let mut msg = IncomingMessage::new("gateway", &user.user_id, &req.content);
|
||||||
|
if state.owner_id != state.default_sender_id && user.user_id == state.owner_id {
|
||||||
|
msg = msg.with_sender_id(&state.default_sender_id);
|
||||||
|
}
|
||||||
// Prefer timezone from JSON body, fall back to X-Timezone header
|
// Prefer timezone from JSON body, fall back to X-Timezone header
|
||||||
let tz = req
|
let tz = req
|
||||||
.timezone
|
.timezone
|
||||||
@@ -1484,6 +1407,9 @@ async fn chat_approval_handler(
|
|||||||
})?;
|
})?;
|
||||||
|
|
||||||
let mut msg = IncomingMessage::new("gateway", &user.user_id, content);
|
let mut msg = IncomingMessage::new("gateway", &user.user_id, content);
|
||||||
|
if state.owner_id != state.default_sender_id && user.user_id == state.owner_id {
|
||||||
|
msg = msg.with_sender_id(&state.default_sender_id);
|
||||||
|
}
|
||||||
|
|
||||||
if let Some(ref thread_id) = req.thread_id {
|
if let Some(ref thread_id) = req.thread_id {
|
||||||
msg = msg.with_thread(thread_id);
|
msg = msg.with_thread(thread_id);
|
||||||
@@ -3059,6 +2985,7 @@ mod tests {
|
|||||||
job_manager: None,
|
job_manager: None,
|
||||||
prompt_queue: None,
|
prompt_queue: None,
|
||||||
owner_id: "test".to_string(),
|
owner_id: "test".to_string(),
|
||||||
|
default_sender_id: "test".to_string(),
|
||||||
shutdown_tx: tokio::sync::RwLock::new(None),
|
shutdown_tx: tokio::sync::RwLock::new(None),
|
||||||
ws_tracker: None,
|
ws_tracker: None,
|
||||||
llm_provider: None,
|
llm_provider: None,
|
||||||
@@ -3073,7 +3000,6 @@ mod tests {
|
|||||||
routine_engine: Arc::new(tokio::sync::RwLock::new(None)),
|
routine_engine: Arc::new(tokio::sync::RwLock::new(None)),
|
||||||
startup_time: std::time::Instant::now(),
|
startup_time: std::time::Instant::now(),
|
||||||
active_config: ActiveConfigSnapshot::default(),
|
active_config: ActiveConfigSnapshot::default(),
|
||||||
secrets_store: None,
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -3140,7 +3066,6 @@ mod tests {
|
|||||||
// without needing the full auth middleware layer.
|
// without needing the full auth middleware layer.
|
||||||
req.extensions_mut().insert(UserIdentity {
|
req.extensions_mut().insert(UserIdentity {
|
||||||
user_id: "test".to_string(),
|
user_id: "test".to_string(),
|
||||||
role: "admin".to_string(),
|
|
||||||
workspace_read_scopes: Vec::new(),
|
workspace_read_scopes: Vec::new(),
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -3225,7 +3150,6 @@ mod tests {
|
|||||||
// without needing the full auth middleware layer.
|
// without needing the full auth middleware layer.
|
||||||
req.extensions_mut().insert(UserIdentity {
|
req.extensions_mut().insert(UserIdentity {
|
||||||
user_id: "test".to_string(),
|
user_id: "test".to_string(),
|
||||||
role: "admin".to_string(),
|
|
||||||
workspace_read_scopes: Vec::new(),
|
workspace_read_scopes: Vec::new(),
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -3275,10 +3199,7 @@ mod tests {
|
|||||||
let state = test_gateway_state(None);
|
let state = test_gateway_state(None);
|
||||||
|
|
||||||
let addr: SocketAddr = "127.0.0.1:0".parse().unwrap();
|
let addr: SocketAddr = "127.0.0.1:0".parse().unwrap();
|
||||||
let auth = CombinedAuthState::from(crate::channels::web::auth::MultiAuthState::single(
|
let auth = MultiAuthState::single("test-token".to_string(), "test".to_string());
|
||||||
"test-token".to_string(),
|
|
||||||
"test".to_string(),
|
|
||||||
));
|
|
||||||
let bound = start_server(addr, state.clone(), auth)
|
let bound = start_server(addr, state.clone(), auth)
|
||||||
.await
|
.await
|
||||||
.expect("server should start");
|
.expect("server should start");
|
||||||
|
|||||||
+27
-151
@@ -186,13 +186,6 @@ function authenticate() {
|
|||||||
connectSSE();
|
connectSSE();
|
||||||
connectLogSSE();
|
connectLogSSE();
|
||||||
startGatewayStatusPolling();
|
startGatewayStatusPolling();
|
||||||
// Hide the Users settings tab for non-admin users.
|
|
||||||
apiFetch('/api/profile').then(function(profile) {
|
|
||||||
if (profile && profile.role !== 'admin') {
|
|
||||||
var usersTab = document.querySelector('[data-settings-subtab="users"]');
|
|
||||||
if (usersTab) usersTab.style.display = 'none';
|
|
||||||
}
|
|
||||||
}).catch(function() {});
|
|
||||||
checkTeeStatus();
|
checkTeeStatus();
|
||||||
loadThreads();
|
loadThreads();
|
||||||
loadMemoryTree();
|
loadMemoryTree();
|
||||||
@@ -4148,6 +4141,7 @@ function renderRoutinesSummary(s) {
|
|||||||
+ summaryCard(I18n.t('routines.summary.total'), s.total, '')
|
+ summaryCard(I18n.t('routines.summary.total'), s.total, '')
|
||||||
+ summaryCard(I18n.t('routines.summary.enabled'), s.enabled, 'active')
|
+ summaryCard(I18n.t('routines.summary.enabled'), s.enabled, 'active')
|
||||||
+ summaryCard(I18n.t('routines.summary.disabled'), s.disabled, '')
|
+ summaryCard(I18n.t('routines.summary.disabled'), s.disabled, '')
|
||||||
|
+ summaryCard(I18n.t('routines.summary.unverified'), s.unverified, 'pending')
|
||||||
+ summaryCard(I18n.t('routines.summary.failing'), s.failing, 'failed')
|
+ summaryCard(I18n.t('routines.summary.failing'), s.failing, 'failed')
|
||||||
+ summaryCard(I18n.t('routines.summary.runsToday'), s.runs_today, 'completed');
|
+ summaryCard(I18n.t('routines.summary.runsToday'), s.runs_today, 'completed');
|
||||||
}
|
}
|
||||||
@@ -4166,6 +4160,8 @@ function renderRoutinesList(routines) {
|
|||||||
tbody.innerHTML = routines.map((r) => {
|
tbody.innerHTML = routines.map((r) => {
|
||||||
const statusClass = r.status === 'active' ? 'completed'
|
const statusClass = r.status === 'active' ? 'completed'
|
||||||
: r.status === 'failing' ? 'failed'
|
: r.status === 'failing' ? 'failed'
|
||||||
|
: r.status === 'attention' ? 'stuck'
|
||||||
|
: r.status === 'running' ? 'in_progress'
|
||||||
: 'pending';
|
: 'pending';
|
||||||
|
|
||||||
const toggleLabel = r.enabled ? 'Disable' : 'Enable';
|
const toggleLabel = r.enabled ? 'Disable' : 'Enable';
|
||||||
@@ -4173,6 +4169,9 @@ function renderRoutinesList(routines) {
|
|||||||
const triggerTitle = (r.trigger_type === 'cron' && r.trigger_raw)
|
const triggerTitle = (r.trigger_type === 'cron' && r.trigger_raw)
|
||||||
? ' title="' + escapeHtml(r.trigger_raw) + '"'
|
? ' title="' + escapeHtml(r.trigger_raw) + '"'
|
||||||
: '';
|
: '';
|
||||||
|
const runLabel = (r.verification_status === 'unverified' || r.status === 'unverified')
|
||||||
|
? 'Verify now'
|
||||||
|
: 'Run';
|
||||||
|
|
||||||
return '<tr class="routine-row" data-action="open-routine" data-id="' + escapeHtml(r.id) + '">'
|
return '<tr class="routine-row" data-action="open-routine" data-id="' + escapeHtml(r.id) + '">'
|
||||||
+ '<td>' + escapeHtml(r.name) + '</td>'
|
+ '<td>' + escapeHtml(r.name) + '</td>'
|
||||||
@@ -4184,7 +4183,7 @@ function renderRoutinesList(routines) {
|
|||||||
+ '<td><span class="badge ' + statusClass + '">' + escapeHtml(r.status) + '</span></td>'
|
+ '<td><span class="badge ' + statusClass + '">' + escapeHtml(r.status) + '</span></td>'
|
||||||
+ '<td>'
|
+ '<td>'
|
||||||
+ '<button class="' + toggleClass + '" data-action="toggle-routine" data-id="' + escapeHtml(r.id) + '">' + toggleLabel + '</button> '
|
+ '<button class="' + toggleClass + '" data-action="toggle-routine" data-id="' + escapeHtml(r.id) + '">' + toggleLabel + '</button> '
|
||||||
+ '<button class="btn-restart" data-action="trigger-routine" data-id="' + escapeHtml(r.id) + '">Run</button> '
|
+ '<button class="btn-restart" data-action="trigger-routine" data-id="' + escapeHtml(r.id) + '">' + runLabel + '</button> '
|
||||||
+ '<button class="btn-cancel" data-action="delete-routine" data-id="' + escapeHtml(r.id) + '" data-name="' + escapeHtml(r.name) + '">Delete</button>'
|
+ '<button class="btn-cancel" data-action="delete-routine" data-id="' + escapeHtml(r.id) + '" data-name="' + escapeHtml(r.name) + '">Delete</button>'
|
||||||
+ '</td>'
|
+ '</td>'
|
||||||
+ '</tr>';
|
+ '</tr>';
|
||||||
@@ -4213,12 +4212,12 @@ function renderRoutineDetail(routine) {
|
|||||||
const detail = document.getElementById('routine-detail');
|
const detail = document.getElementById('routine-detail');
|
||||||
detail.style.display = 'block';
|
detail.style.display = 'block';
|
||||||
|
|
||||||
const statusClass = !routine.enabled ? 'pending'
|
const statusClass = routine.status === 'active' ? 'completed'
|
||||||
: routine.consecutive_failures > 0 ? 'failed'
|
: routine.status === 'failing' ? 'failed'
|
||||||
: 'completed';
|
: routine.status === 'attention' ? 'stuck'
|
||||||
const statusLabel = !routine.enabled ? 'disabled'
|
: routine.status === 'running' ? 'in_progress'
|
||||||
: routine.consecutive_failures > 0 ? 'failing'
|
: 'pending';
|
||||||
: 'active';
|
const statusLabel = routine.status || 'active';
|
||||||
|
|
||||||
let html = '<div class="job-detail-header">'
|
let html = '<div class="job-detail-header">'
|
||||||
+ '<button class="btn-back" data-action="close-routine-detail">← Back</button>'
|
+ '<button class="btn-back" data-action="close-routine-detail">← Back</button>'
|
||||||
@@ -4243,6 +4242,20 @@ function renderRoutineDetail(routine) {
|
|||||||
+ '<div class="job-description-body">' + escapeHtml(routine.description) + '</div></div>';
|
+ '<div class="job-description-body">' + escapeHtml(routine.description) + '</div></div>';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (routine.verification_status === 'unverified') {
|
||||||
|
let verificationCopy = 'Created or updated, but not yet verified with a successful run.';
|
||||||
|
if (routine.recent_runs && routine.recent_runs.length > 0) {
|
||||||
|
const latestRun = routine.recent_runs[0];
|
||||||
|
if (latestRun.status === 'failed') {
|
||||||
|
verificationCopy = 'The latest verification attempt failed. Review the run details and verify again after fixing it.';
|
||||||
|
} else if (latestRun.status === 'attention') {
|
||||||
|
verificationCopy = 'The latest verification attempt needs attention. Review the run details and verify again when ready.';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
html += '<div class="job-description"><h3>Verification</h3>'
|
||||||
|
+ '<div class="job-description-body">' + escapeHtml(verificationCopy) + '</div></div>';
|
||||||
|
}
|
||||||
|
|
||||||
// Trigger config
|
// Trigger config
|
||||||
if (routine.trigger_type === 'cron') {
|
if (routine.trigger_type === 'cron') {
|
||||||
const summary = routine.trigger_summary || 'cron';
|
const summary = routine.trigger_summary || 'cron';
|
||||||
@@ -4346,142 +4359,6 @@ function formatRelativeTime(isoString) {
|
|||||||
return future ? I18n.t('time.daysFromNow', { n: days }) : I18n.t('time.daysAgo', { n: days });
|
return future ? I18n.t('time.daysFromNow', { n: days }) : I18n.t('time.daysAgo', { n: days });
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Users (admin) ---
|
|
||||||
|
|
||||||
function loadUsers() {
|
|
||||||
apiFetch('/api/admin/users').then(function(data) {
|
|
||||||
renderUsersList(data.users || []);
|
|
||||||
}).catch(function(err) {
|
|
||||||
// Non-admin users get 403 — show a message instead of an error
|
|
||||||
var tbody = document.getElementById('users-tbody');
|
|
||||||
var empty = document.getElementById('users-empty');
|
|
||||||
if (tbody) tbody.innerHTML = '';
|
|
||||||
if (empty) {
|
|
||||||
empty.style.display = 'block';
|
|
||||||
empty.textContent = 'Admin access required to manage users.';
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function renderUsersList(users) {
|
|
||||||
var tbody = document.getElementById('users-tbody');
|
|
||||||
var empty = document.getElementById('users-empty');
|
|
||||||
if (!users || users.length === 0) {
|
|
||||||
tbody.innerHTML = '';
|
|
||||||
empty.style.display = 'block';
|
|
||||||
empty.textContent = 'No users found. Create the first user to get started.';
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
empty.style.display = 'none';
|
|
||||||
tbody.innerHTML = users.map(function(u) {
|
|
||||||
var statusClass = u.status === 'active' ? 'active' : 'failed';
|
|
||||||
var roleLabel = u.role === 'admin' ? '<span class="badge badge-admin">admin</span>' : '<span class="badge">member</span>';
|
|
||||||
var actions = '';
|
|
||||||
if (u.status === 'active') {
|
|
||||||
actions += '<button class="btn-small btn-danger" data-action="suspend-user" data-user-id="' + escapeHtml(u.id) + '">Suspend</button> ';
|
|
||||||
} else {
|
|
||||||
actions += '<button class="btn-small btn-primary" data-action="activate-user" data-user-id="' + escapeHtml(u.id) + '">Activate</button> ';
|
|
||||||
}
|
|
||||||
actions += '<button class="btn-small" data-action="create-token" data-user-id="' + escapeHtml(u.id) + '" data-user-name="' + escapeHtml(u.display_name) + '">+ Token</button>';
|
|
||||||
return '<tr>'
|
|
||||||
+ '<td class="user-id" title="' + escapeHtml(u.id) + '">' + escapeHtml(u.id.substring(0, 8)) + '…</td>'
|
|
||||||
+ '<td>' + escapeHtml(u.display_name) + '</td>'
|
|
||||||
+ '<td>' + escapeHtml(u.email || '—') + '</td>'
|
|
||||||
+ '<td>' + roleLabel + '</td>'
|
|
||||||
+ '<td><span class="status-badge ' + statusClass + '">' + escapeHtml(u.status) + '</span></td>'
|
|
||||||
+ '<td>' + formatRelativeTime(u.created_at) + '</td>'
|
|
||||||
+ '<td>' + actions + '</td>'
|
|
||||||
+ '</tr>';
|
|
||||||
}).join('');
|
|
||||||
}
|
|
||||||
|
|
||||||
function suspendUser(userId) {
|
|
||||||
apiFetch('/api/admin/users/' + userId + '/suspend', { method: 'POST' })
|
|
||||||
.then(function() { loadUsers(); })
|
|
||||||
.catch(function(e) { alert('Failed to suspend user: ' + e.message); });
|
|
||||||
}
|
|
||||||
|
|
||||||
function activateUser(userId) {
|
|
||||||
apiFetch('/api/admin/users/' + userId + '/activate', { method: 'POST' })
|
|
||||||
.then(function() { loadUsers(); })
|
|
||||||
.catch(function(e) { alert('Failed to activate user: ' + e.message); });
|
|
||||||
}
|
|
||||||
|
|
||||||
function createTokenForUser(userId, displayName) {
|
|
||||||
var tokenName = prompt('Token name for ' + displayName + ':', 'api-token');
|
|
||||||
if (!tokenName) return;
|
|
||||||
apiFetch('/api/tokens', {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify({ name: tokenName, user_id: userId }),
|
|
||||||
}).then(function(data) {
|
|
||||||
showTokenBanner(data.token);
|
|
||||||
}).catch(function(e) { alert('Failed to create token: ' + e.message); });
|
|
||||||
}
|
|
||||||
|
|
||||||
function showTokenBanner(tokenValue) {
|
|
||||||
var banner = document.getElementById('users-token-result');
|
|
||||||
if (!banner) return;
|
|
||||||
var loginUrl = window.location.origin + '/?token=' + encodeURIComponent(tokenValue);
|
|
||||||
banner.style.display = 'block';
|
|
||||||
banner.innerHTML = '<strong>User created!</strong> Share this login link — it won\'t be shown again:<br>'
|
|
||||||
+ '<code class="token-display" id="token-copy-value">' + escapeHtml(loginUrl) + '</code>'
|
|
||||||
+ '<button class="btn-small" id="token-copy-link">Copy Link</button>'
|
|
||||||
+ '<br><span style="font-size:0.8em;color:var(--text-muted)">Raw token: ' + escapeHtml(tokenValue) + '</span>';
|
|
||||||
document.getElementById('token-copy-link').addEventListener('click', function() {
|
|
||||||
navigator.clipboard.writeText(loginUrl);
|
|
||||||
this.textContent = 'Copied!';
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Delegated click handler for user action buttons (CSP-safe, no inline onclick)
|
|
||||||
document.getElementById('users-table')?.addEventListener('click', function(e) {
|
|
||||||
var btn = e.target.closest('[data-action]');
|
|
||||||
if (!btn) return;
|
|
||||||
var action = btn.getAttribute('data-action');
|
|
||||||
var userId = btn.getAttribute('data-user-id');
|
|
||||||
var userName = btn.getAttribute('data-user-name');
|
|
||||||
if (action === 'suspend-user') suspendUser(userId);
|
|
||||||
else if (action === 'activate-user') activateUser(userId);
|
|
||||||
else if (action === 'create-token') createTokenForUser(userId, userName || '');
|
|
||||||
});
|
|
||||||
|
|
||||||
// Wire up Users tab create form
|
|
||||||
document.getElementById('users-create-btn')?.addEventListener('click', function() {
|
|
||||||
document.getElementById('users-create-form').style.display = 'flex';
|
|
||||||
document.getElementById('users-token-result').style.display = 'none';
|
|
||||||
document.getElementById('user-display-name').focus();
|
|
||||||
});
|
|
||||||
|
|
||||||
document.getElementById('users-create-cancel')?.addEventListener('click', function() {
|
|
||||||
document.getElementById('users-create-form').style.display = 'none';
|
|
||||||
});
|
|
||||||
|
|
||||||
document.getElementById('users-create-submit')?.addEventListener('click', function() {
|
|
||||||
var displayName = document.getElementById('user-display-name').value.trim();
|
|
||||||
var email = document.getElementById('user-email').value.trim();
|
|
||||||
var role = document.getElementById('user-role').value;
|
|
||||||
if (!displayName) { alert('Display name is required'); return; }
|
|
||||||
|
|
||||||
apiFetch('/api/admin/users', {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify({
|
|
||||||
display_name: displayName,
|
|
||||||
email: email || undefined,
|
|
||||||
role: role,
|
|
||||||
}),
|
|
||||||
}).then(function(data) {
|
|
||||||
document.getElementById('users-create-form').style.display = 'none';
|
|
||||||
document.getElementById('user-display-name').value = '';
|
|
||||||
document.getElementById('user-email').value = '';
|
|
||||||
if (data.token) {
|
|
||||||
showTokenBanner(data.token);
|
|
||||||
}
|
|
||||||
loadUsers();
|
|
||||||
}).catch(function(e) { alert('Failed to create user: ' + e.message); });
|
|
||||||
});
|
|
||||||
|
|
||||||
// --- Gateway status widget ---
|
// --- Gateway status widget ---
|
||||||
|
|
||||||
let gatewayStatusInterval = null;
|
let gatewayStatusInterval = null;
|
||||||
@@ -5171,7 +5048,6 @@ function loadSettingsSubtab(subtab) {
|
|||||||
else if (subtab === 'extensions') { loadExtensions(); startPairingPoll(); }
|
else if (subtab === 'extensions') { loadExtensions(); startPairingPoll(); }
|
||||||
else if (subtab === 'mcp') loadMcpServers();
|
else if (subtab === 'mcp') loadMcpServers();
|
||||||
else if (subtab === 'skills') loadSkills();
|
else if (subtab === 'skills') loadSkills();
|
||||||
else if (subtab === 'users') loadUsers();
|
|
||||||
if (subtab !== 'extensions' && subtab !== 'channels') stopPairingPoll();
|
if (subtab !== 'extensions' && subtab !== 'channels') stopPairingPoll();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -9,6 +9,8 @@ I18n.register('en', {
|
|||||||
'auth.connect': 'Connect',
|
'auth.connect': 'Connect',
|
||||||
'auth.errorRequired': 'Token required',
|
'auth.errorRequired': 'Token required',
|
||||||
'auth.errorInvalid': 'Invalid token',
|
'auth.errorInvalid': 'Invalid token',
|
||||||
|
'auth.hint': 'Enter the GATEWAY_AUTH_TOKEN from your .env file',
|
||||||
|
|
||||||
// Chat
|
// Chat
|
||||||
'chat.inputPlaceholder': 'Message or / for commands...',
|
'chat.inputPlaceholder': 'Message or / for commands...',
|
||||||
|
|
||||||
@@ -42,8 +44,7 @@ I18n.register('en', {
|
|||||||
'settings.channels': 'Channels',
|
'settings.channels': 'Channels',
|
||||||
'settings.networking': 'Networking',
|
'settings.networking': 'Networking',
|
||||||
'settings.mcp': 'MCP',
|
'settings.mcp': 'MCP',
|
||||||
'settings.users': 'Users',
|
|
||||||
|
|
||||||
// Status
|
// Status
|
||||||
'status.connected': 'Connected',
|
'status.connected': 'Connected',
|
||||||
'status.disconnected': 'Disconnected',
|
'status.disconnected': 'Disconnected',
|
||||||
@@ -206,6 +207,7 @@ I18n.register('en', {
|
|||||||
'routines.summary.total': 'Total',
|
'routines.summary.total': 'Total',
|
||||||
'routines.summary.enabled': 'Enabled',
|
'routines.summary.enabled': 'Enabled',
|
||||||
'routines.summary.disabled': 'Disabled',
|
'routines.summary.disabled': 'Disabled',
|
||||||
|
'routines.summary.unverified': 'Unverified',
|
||||||
'routines.summary.failing': 'Failing',
|
'routines.summary.failing': 'Failing',
|
||||||
'routines.summary.runsToday': 'Runs Today',
|
'routines.summary.runsToday': 'Runs Today',
|
||||||
|
|
||||||
|
|||||||
@@ -9,6 +9,8 @@ I18n.register('zh-CN', {
|
|||||||
'auth.connect': '连接',
|
'auth.connect': '连接',
|
||||||
'auth.errorRequired': '请输入令牌',
|
'auth.errorRequired': '请输入令牌',
|
||||||
'auth.errorInvalid': '令牌无效',
|
'auth.errorInvalid': '令牌无效',
|
||||||
|
'auth.hint': '输入 .env 配置文件中的 GATEWAY_AUTH_TOKEN',
|
||||||
|
|
||||||
// 聊天
|
// 聊天
|
||||||
'chat.inputPlaceholder': '输入消息或 / 以使用命令...',
|
'chat.inputPlaceholder': '输入消息或 / 以使用命令...',
|
||||||
|
|
||||||
@@ -42,8 +44,7 @@ I18n.register('zh-CN', {
|
|||||||
'settings.channels': '频道',
|
'settings.channels': '频道',
|
||||||
'settings.networking': '网络',
|
'settings.networking': '网络',
|
||||||
'settings.mcp': 'MCP',
|
'settings.mcp': 'MCP',
|
||||||
'settings.users': '用户管理',
|
|
||||||
|
|
||||||
// 状态
|
// 状态
|
||||||
'status.connected': '已连接',
|
'status.connected': '已连接',
|
||||||
'status.disconnected': '已断开',
|
'status.disconnected': '已断开',
|
||||||
@@ -206,6 +207,7 @@ I18n.register('zh-CN', {
|
|||||||
'routines.summary.total': '总计',
|
'routines.summary.total': '总计',
|
||||||
'routines.summary.enabled': '已启用',
|
'routines.summary.enabled': '已启用',
|
||||||
'routines.summary.disabled': '已禁用',
|
'routines.summary.disabled': '已禁用',
|
||||||
|
'routines.summary.unverified': '未验证',
|
||||||
'routines.summary.failing': '失败',
|
'routines.summary.failing': '失败',
|
||||||
'routines.summary.runsToday': '今日运行',
|
'routines.summary.runsToday': '今日运行',
|
||||||
|
|
||||||
|
|||||||
@@ -41,6 +41,7 @@
|
|||||||
<button id="auth-connect-btn" data-i18n="auth.connect">Connect</button>
|
<button id="auth-connect-btn" data-i18n="auth.connect">Connect</button>
|
||||||
</div>
|
</div>
|
||||||
<div id="auth-error"></div>
|
<div id="auth-error"></div>
|
||||||
|
<p class="auth-hint" data-i18n="auth.hint">Enter the GATEWAY_AUTH_TOKEN from your .env configuration.</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -292,7 +293,6 @@
|
|||||||
<button class="settings-subtab" data-settings-subtab="extensions" data-i18n="tab.extensions">Extensions</button>
|
<button class="settings-subtab" data-settings-subtab="extensions" data-i18n="tab.extensions">Extensions</button>
|
||||||
<button class="settings-subtab" data-settings-subtab="mcp" data-i18n="settings.mcp">MCP</button>
|
<button class="settings-subtab" data-settings-subtab="mcp" data-i18n="settings.mcp">MCP</button>
|
||||||
<button class="settings-subtab" data-settings-subtab="skills" data-i18n="tab.skills">Skills</button>
|
<button class="settings-subtab" data-settings-subtab="skills" data-i18n="tab.skills">Skills</button>
|
||||||
<button class="settings-subtab" data-settings-subtab="users" data-i18n="settings.users">Users</button>
|
|
||||||
<button class="settings-theme-toggle" id="settings-theme-toggle" data-i18n="theme.tooltipSystem" title="Toggle theme">Theme</button>
|
<button class="settings-theme-toggle" id="settings-theme-toggle" data-i18n="theme.tooltipSystem" title="Toggle theme">Theme</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="settings-content">
|
<div class="settings-content">
|
||||||
@@ -390,29 +390,6 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="settings-subpanel" id="settings-users">
|
|
||||||
<div class="users-container">
|
|
||||||
<div class="users-header">
|
|
||||||
<h3>User Management</h3>
|
|
||||||
<button id="users-create-btn" class="btn-primary">+ New User</button>
|
|
||||||
</div>
|
|
||||||
<div id="users-create-form" style="display:none" class="users-form" autocomplete="off">
|
|
||||||
<input type="text" id="user-display-name" placeholder="Display name" autocomplete="off" />
|
|
||||||
<input type="text" id="user-email" placeholder="Email (optional)" autocomplete="off" />
|
|
||||||
<select id="user-role"><option value="member">Member</option><option value="admin">Admin</option></select>
|
|
||||||
<button id="users-create-submit" class="btn-primary">Create</button>
|
|
||||||
<button id="users-create-cancel" class="btn-secondary">Cancel</button>
|
|
||||||
</div>
|
|
||||||
<div id="users-token-result" style="display:none" class="users-token-banner"></div>
|
|
||||||
<table class="routines-table" id="users-table">
|
|
||||||
<thead><tr>
|
|
||||||
<th>ID</th><th>Display Name</th><th>Email</th><th>Role</th><th>Status</th><th>Created</th><th>Actions</th>
|
|
||||||
</tr></thead>
|
|
||||||
<tbody id="users-tbody"></tbody>
|
|
||||||
</table>
|
|
||||||
<div id="users-empty" class="empty-state" style="display:none">No users found. Create the first user to get started.</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -5429,22 +5429,3 @@ body.theme-transition *:not(svg):not(path):not(line):not(circle):not(rect) {
|
|||||||
--text-muted: #a1a1aa;
|
--text-muted: #a1a1aa;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* --- Users Tab --- */
|
|
||||||
.users-container { padding: 1rem; }
|
|
||||||
.users-header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 1rem; }
|
|
||||||
.users-header h3 { margin: 0; font-size: 1.1rem; }
|
|
||||||
.users-form { display: flex; gap: 0.5rem; align-items: center; margin-bottom: 1rem; flex-wrap: wrap; }
|
|
||||||
.users-form input, .users-form select { padding: 0.4rem 0.6rem; border-radius: 6px; border: 1px solid var(--border); background: var(--bg-secondary); color: var(--text-primary); font-size: 0.85rem; }
|
|
||||||
.users-token-banner { background: var(--bg-tertiary); border: 1px solid var(--accent); border-radius: 8px; padding: 0.75rem 1rem; margin-bottom: 1rem; font-size: 0.85rem; }
|
|
||||||
.token-display { display: inline-block; padding: 0.3rem 0.6rem; background: var(--bg-primary); border-radius: 4px; font-family: var(--font-mono); word-break: break-all; margin: 0.4rem 0; user-select: all; }
|
|
||||||
.user-id { font-family: var(--font-mono); font-size: 0.8rem; color: var(--text-muted); }
|
|
||||||
.badge { display: inline-block; padding: 0.15rem 0.5rem; border-radius: 10px; font-size: 0.75rem; background: var(--bg-tertiary); color: var(--text-secondary); }
|
|
||||||
.badge-admin { background: var(--accent); color: #fff; }
|
|
||||||
.btn-small { padding: 0.25rem 0.5rem; font-size: 0.75rem; border-radius: 4px; border: 1px solid var(--border); background: var(--bg-secondary); color: var(--text-primary); cursor: pointer; }
|
|
||||||
.btn-small:hover { background: var(--bg-tertiary); }
|
|
||||||
.btn-danger { border-color: #ef4444; color: #ef4444; }
|
|
||||||
.btn-danger:hover { background: #ef4444; color: #fff; }
|
|
||||||
.btn-primary { background: var(--accent); color: #fff; border: none; padding: 0.4rem 0.8rem; border-radius: 6px; cursor: pointer; font-size: 0.85rem; }
|
|
||||||
.btn-primary:hover { opacity: 0.9; }
|
|
||||||
.btn-secondary { background: var(--bg-tertiary); color: var(--text-primary); border: 1px solid var(--border); padding: 0.4rem 0.8rem; border-radius: 6px; cursor: pointer; font-size: 0.85rem; }
|
|
||||||
|
|||||||
@@ -77,6 +77,7 @@ impl TestGatewayBuilder {
|
|||||||
job_manager: None,
|
job_manager: None,
|
||||||
prompt_queue: None,
|
prompt_queue: None,
|
||||||
owner_id: self.user_id.clone(),
|
owner_id: self.user_id.clone(),
|
||||||
|
default_sender_id: self.user_id,
|
||||||
shutdown_tx: tokio::sync::RwLock::new(None),
|
shutdown_tx: tokio::sync::RwLock::new(None),
|
||||||
ws_tracker: Some(Arc::new(WsConnectionTracker::new())),
|
ws_tracker: Some(Arc::new(WsConnectionTracker::new())),
|
||||||
llm_provider: self.llm_provider,
|
llm_provider: self.llm_provider,
|
||||||
@@ -91,7 +92,6 @@ impl TestGatewayBuilder {
|
|||||||
routine_engine: Arc::new(tokio::sync::RwLock::new(None)),
|
routine_engine: Arc::new(tokio::sync::RwLock::new(None)),
|
||||||
startup_time: std::time::Instant::now(),
|
startup_time: std::time::Instant::now(),
|
||||||
active_config: crate::channels::web::server::ActiveConfigSnapshot::default(),
|
active_config: crate::channels::web::server::ActiveConfigSnapshot::default(),
|
||||||
secrets_store: None,
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -106,7 +106,7 @@ impl TestGatewayBuilder {
|
|||||||
let addr: SocketAddr = "127.0.0.1:0"
|
let addr: SocketAddr = "127.0.0.1:0"
|
||||||
.parse()
|
.parse()
|
||||||
.expect("hard-coded address must parse"); // safety: constant literal
|
.expect("hard-coded address must parse"); // safety: constant literal
|
||||||
let bound = start_server(addr, state.clone(), auth.into()).await?;
|
let bound = start_server(addr, state.clone(), auth).await?;
|
||||||
Ok((bound, state))
|
Ok((bound, state))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -120,7 +120,7 @@ impl TestGatewayBuilder {
|
|||||||
let addr: SocketAddr = "127.0.0.1:0"
|
let addr: SocketAddr = "127.0.0.1:0"
|
||||||
.parse()
|
.parse()
|
||||||
.expect("hard-coded address must parse"); // safety: constant literal
|
.expect("hard-coded address must parse"); // safety: constant literal
|
||||||
let bound = start_server(addr, state.clone(), auth.into()).await?;
|
let bound = start_server(addr, state.clone(), auth).await?;
|
||||||
Ok((bound, state))
|
Ok((bound, state))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ use axum::routing::{delete, get, post};
|
|||||||
use tower::ServiceExt;
|
use tower::ServiceExt;
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
use crate::channels::web::GatewayChannel;
|
||||||
use crate::channels::web::auth::{
|
use crate::channels::web::auth::{
|
||||||
AuthenticatedUser, MultiAuthState, UserIdentity, auth_middleware,
|
AuthenticatedUser, MultiAuthState, UserIdentity, auth_middleware,
|
||||||
};
|
};
|
||||||
@@ -23,6 +24,7 @@ use crate::channels::web::server::{
|
|||||||
ActiveConfigSnapshot, GatewayState, PerUserRateLimiter, PromptQueue, RateLimiter, WorkspacePool,
|
ActiveConfigSnapshot, GatewayState, PerUserRateLimiter, PromptQueue, RateLimiter, WorkspacePool,
|
||||||
};
|
};
|
||||||
use crate::channels::web::sse::SseManager;
|
use crate::channels::web::sse::SseManager;
|
||||||
|
use crate::config::GatewayConfig;
|
||||||
|
|
||||||
// ── Helpers ────────────────────────────────────────────────────────────
|
// ── Helpers ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -33,7 +35,6 @@ fn two_user_auth() -> MultiAuthState {
|
|||||||
"tok-alice".to_string(),
|
"tok-alice".to_string(),
|
||||||
UserIdentity {
|
UserIdentity {
|
||||||
user_id: "alice".to_string(),
|
user_id: "alice".to_string(),
|
||||||
role: "admin".to_string(),
|
|
||||||
workspace_read_scopes: vec!["shared".to_string()],
|
workspace_read_scopes: vec!["shared".to_string()],
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
@@ -41,7 +42,6 @@ fn two_user_auth() -> MultiAuthState {
|
|||||||
"tok-bob".to_string(),
|
"tok-bob".to_string(),
|
||||||
UserIdentity {
|
UserIdentity {
|
||||||
user_id: "bob".to_string(),
|
user_id: "bob".to_string(),
|
||||||
role: "admin".to_string(),
|
|
||||||
workspace_read_scopes: vec!["shared".to_string(), "alice".to_string()],
|
workspace_read_scopes: vec!["shared".to_string(), "alice".to_string()],
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
@@ -67,6 +67,7 @@ fn build_state(
|
|||||||
job_manager: None,
|
job_manager: None,
|
||||||
prompt_queue,
|
prompt_queue,
|
||||||
owner_id: "test".to_string(),
|
owner_id: "test".to_string(),
|
||||||
|
default_sender_id: "test".to_string(),
|
||||||
shutdown_tx: tokio::sync::RwLock::new(None),
|
shutdown_tx: tokio::sync::RwLock::new(None),
|
||||||
ws_tracker: None,
|
ws_tracker: None,
|
||||||
llm_provider: None,
|
llm_provider: None,
|
||||||
@@ -81,10 +82,43 @@ fn build_state(
|
|||||||
routine_engine: Arc::new(tokio::sync::RwLock::new(None)),
|
routine_engine: Arc::new(tokio::sync::RwLock::new(None)),
|
||||||
startup_time: std::time::Instant::now(),
|
startup_time: std::time::Instant::now(),
|
||||||
active_config: ActiveConfigSnapshot::default(),
|
active_config: ActiveConfigSnapshot::default(),
|
||||||
secrets_store: None,
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn gateway_config() -> GatewayConfig {
|
||||||
|
GatewayConfig {
|
||||||
|
host: "127.0.0.1".to_string(),
|
||||||
|
port: 3000,
|
||||||
|
auth_token: Some("gateway-auth".to_string()),
|
||||||
|
user_id: "gateway-sender".to_string(),
|
||||||
|
workspace_read_scopes: Vec::new(),
|
||||||
|
memory_layers: Vec::new(),
|
||||||
|
user_tokens: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn with_owner_scope_updates_gateway_owner_scope_in_multi_user_mode() {
|
||||||
|
let mut gateway = GatewayChannel::new(gateway_config());
|
||||||
|
gateway.auth = two_user_auth();
|
||||||
|
gateway.config.user_tokens = Some(HashMap::new());
|
||||||
|
let gateway = gateway.with_owner_scope("owner-scope");
|
||||||
|
|
||||||
|
assert_eq!(gateway.state.owner_id, "owner-scope");
|
||||||
|
assert_eq!(gateway.state.default_sender_id, "gateway-sender");
|
||||||
|
|
||||||
|
let alice = gateway
|
||||||
|
.auth
|
||||||
|
.authenticate("tok-alice")
|
||||||
|
.expect("alice token should remain valid");
|
||||||
|
let bob = gateway
|
||||||
|
.auth
|
||||||
|
.authenticate("tok-bob")
|
||||||
|
.expect("bob token should remain valid");
|
||||||
|
assert_eq!(alice.user_id, "alice");
|
||||||
|
assert_eq!(bob.user_id, "bob");
|
||||||
|
}
|
||||||
|
|
||||||
/// Create a libSQL-backed test database in a temporary directory.
|
/// Create a libSQL-backed test database in a temporary directory.
|
||||||
///
|
///
|
||||||
/// Returns the database and a `TempDir` guard — the database file is
|
/// Returns the database and a `TempDir` guard — the database file is
|
||||||
@@ -191,7 +225,6 @@ mod workspace_pool {
|
|||||||
);
|
);
|
||||||
let identity = UserIdentity {
|
let identity = UserIdentity {
|
||||||
user_id: "alice".to_string(),
|
user_id: "alice".to_string(),
|
||||||
role: "admin".to_string(),
|
|
||||||
workspace_read_scopes: vec![],
|
workspace_read_scopes: vec![],
|
||||||
};
|
};
|
||||||
let ws = pool.get_or_create(&identity).await;
|
let ws = pool.get_or_create(&identity).await;
|
||||||
@@ -220,7 +253,6 @@ mod workspace_pool {
|
|||||||
);
|
);
|
||||||
let identity = UserIdentity {
|
let identity = UserIdentity {
|
||||||
user_id: "alice".to_string(),
|
user_id: "alice".to_string(),
|
||||||
role: "admin".to_string(),
|
|
||||||
workspace_read_scopes: vec![],
|
workspace_read_scopes: vec![],
|
||||||
};
|
};
|
||||||
let ws = pool.get_or_create(&identity).await;
|
let ws = pool.get_or_create(&identity).await;
|
||||||
@@ -244,7 +276,6 @@ mod workspace_pool {
|
|||||||
);
|
);
|
||||||
let identity = UserIdentity {
|
let identity = UserIdentity {
|
||||||
user_id: "bob".to_string(),
|
user_id: "bob".to_string(),
|
||||||
role: "admin".to_string(),
|
|
||||||
workspace_read_scopes: vec!["alice".to_string(), "shared".to_string()],
|
workspace_read_scopes: vec!["alice".to_string(), "shared".to_string()],
|
||||||
};
|
};
|
||||||
let ws = pool.get_or_create(&identity).await;
|
let ws = pool.get_or_create(&identity).await;
|
||||||
@@ -271,12 +302,10 @@ mod workspace_pool {
|
|||||||
);
|
);
|
||||||
let alice_id = UserIdentity {
|
let alice_id = UserIdentity {
|
||||||
user_id: "alice".to_string(),
|
user_id: "alice".to_string(),
|
||||||
role: "admin".to_string(),
|
|
||||||
workspace_read_scopes: vec![],
|
workspace_read_scopes: vec![],
|
||||||
};
|
};
|
||||||
let bob_id = UserIdentity {
|
let bob_id = UserIdentity {
|
||||||
user_id: "bob".to_string(),
|
user_id: "bob".to_string(),
|
||||||
role: "admin".to_string(),
|
|
||||||
workspace_read_scopes: vec![],
|
workspace_read_scopes: vec![],
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -308,7 +337,6 @@ mod workspace_pool {
|
|||||||
);
|
);
|
||||||
let identity = UserIdentity {
|
let identity = UserIdentity {
|
||||||
user_id: "alice".to_string(),
|
user_id: "alice".to_string(),
|
||||||
role: "admin".to_string(),
|
|
||||||
workspace_read_scopes: vec!["token-scope".to_string()],
|
workspace_read_scopes: vec!["token-scope".to_string()],
|
||||||
};
|
};
|
||||||
let ws = pool.get_or_create(&identity).await;
|
let ws = pool.get_or_create(&identity).await;
|
||||||
@@ -349,10 +377,7 @@ mod jobs_isolation {
|
|||||||
.route("/api/jobs/{id}/cancel", post(jobs_cancel_handler))
|
.route("/api/jobs/{id}/cancel", post(jobs_cancel_handler))
|
||||||
.route("/api/jobs/{id}/restart", post(jobs_restart_handler))
|
.route("/api/jobs/{id}/restart", post(jobs_restart_handler))
|
||||||
.route("/api/jobs/{id}/prompt", post(jobs_prompt_handler))
|
.route("/api/jobs/{id}/prompt", post(jobs_prompt_handler))
|
||||||
.layer(middleware::from_fn_with_state(
|
.layer(middleware::from_fn_with_state(auth, auth_middleware))
|
||||||
crate::channels::web::auth::CombinedAuthState::from(auth),
|
|
||||||
auth_middleware,
|
|
||||||
))
|
|
||||||
.with_state(state)
|
.with_state(state)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -558,10 +583,7 @@ mod routines_isolation {
|
|||||||
.route("/api/routines/{id}", get(routines_detail_handler))
|
.route("/api/routines/{id}", get(routines_detail_handler))
|
||||||
.route("/api/routines/{id}/toggle", post(routines_toggle_handler))
|
.route("/api/routines/{id}/toggle", post(routines_toggle_handler))
|
||||||
.route("/api/routines/{id}", delete(routines_delete_handler))
|
.route("/api/routines/{id}", delete(routines_delete_handler))
|
||||||
.layer(middleware::from_fn_with_state(
|
.layer(middleware::from_fn_with_state(auth, auth_middleware))
|
||||||
crate::channels::web::auth::CombinedAuthState::from(auth),
|
|
||||||
auth_middleware,
|
|
||||||
))
|
|
||||||
.with_state(state)
|
.with_state(state)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -686,10 +708,7 @@ mod auth_enforcement {
|
|||||||
.route("/api/logs/level", get(authed_handler).put(authed_handler))
|
.route("/api/logs/level", get(authed_handler).put(authed_handler))
|
||||||
// Gateway status
|
// Gateway status
|
||||||
.route("/api/gateway/status", get(authed_handler))
|
.route("/api/gateway/status", get(authed_handler))
|
||||||
.layer(middleware::from_fn_with_state(
|
.layer(middleware::from_fn_with_state(auth, auth_middleware))
|
||||||
crate::channels::web::auth::CombinedAuthState::from(auth),
|
|
||||||
auth_middleware,
|
|
||||||
))
|
|
||||||
.with_state(state)
|
.with_state(state)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -812,140 +831,3 @@ mod auth_enforcement {
|
|||||||
assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
|
assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ═══════════════════════════════════════════════════════════════════════
|
|
||||||
// Admin Endpoint Role Enforcement Tests
|
|
||||||
// ═══════════════════════════════════════════════════════════════════════
|
|
||||||
|
|
||||||
mod admin_role_enforcement {
|
|
||||||
use super::*;
|
|
||||||
use crate::channels::web::handlers::users::{
|
|
||||||
users_activate_handler, users_detail_handler, users_list_handler, users_suspend_handler,
|
|
||||||
users_update_handler,
|
|
||||||
};
|
|
||||||
use axum::routing::patch;
|
|
||||||
|
|
||||||
/// Build a router with admin user endpoints behind multi-user auth.
|
|
||||||
/// Uses a member-role token and an admin-role token.
|
|
||||||
fn admin_router() -> Router {
|
|
||||||
let mut tokens = HashMap::new();
|
|
||||||
tokens.insert(
|
|
||||||
"tok-admin".to_string(),
|
|
||||||
UserIdentity {
|
|
||||||
user_id: "admin-user".to_string(),
|
|
||||||
role: "admin".to_string(),
|
|
||||||
workspace_read_scopes: vec![],
|
|
||||||
},
|
|
||||||
);
|
|
||||||
tokens.insert(
|
|
||||||
"tok-member".to_string(),
|
|
||||||
UserIdentity {
|
|
||||||
user_id: "member-user".to_string(),
|
|
||||||
role: "member".to_string(),
|
|
||||||
workspace_read_scopes: vec![],
|
|
||||||
},
|
|
||||||
);
|
|
||||||
let auth = MultiAuthState::multi(tokens);
|
|
||||||
let state = build_state(None, None);
|
|
||||||
|
|
||||||
Router::new()
|
|
||||||
.route("/api/admin/users", get(users_list_handler))
|
|
||||||
.route("/api/admin/users/{id}", get(users_detail_handler))
|
|
||||||
.route("/api/admin/users/{id}", patch(users_update_handler))
|
|
||||||
.route("/api/admin/users/{id}/suspend", post(users_suspend_handler))
|
|
||||||
.route(
|
|
||||||
"/api/admin/users/{id}/activate",
|
|
||||||
post(users_activate_handler),
|
|
||||||
)
|
|
||||||
.layer(middleware::from_fn_with_state(
|
|
||||||
crate::channels::web::auth::CombinedAuthState::from(auth),
|
|
||||||
auth_middleware,
|
|
||||||
))
|
|
||||||
.with_state(state)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Assert a request returns FORBIDDEN for a member token.
|
|
||||||
async fn assert_forbidden_for_member(app: &Router, method: Method, uri: &str) {
|
|
||||||
let req = Request::builder()
|
|
||||||
.method(method)
|
|
||||||
.uri(uri)
|
|
||||||
.header("Authorization", "Bearer tok-member")
|
|
||||||
.body(Body::empty())
|
|
||||||
.unwrap();
|
|
||||||
let resp = app.clone().oneshot(req).await.unwrap();
|
|
||||||
assert_eq!(
|
|
||||||
resp.status(),
|
|
||||||
StatusCode::FORBIDDEN,
|
|
||||||
"expected 403 for member on {}",
|
|
||||||
uri
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn test_admin_user_endpoints_reject_member_role() {
|
|
||||||
let app = admin_router();
|
|
||||||
|
|
||||||
assert_forbidden_for_member(&app, Method::GET, "/api/admin/users").await;
|
|
||||||
assert_forbidden_for_member(&app, Method::GET, "/api/admin/users/some-id").await;
|
|
||||||
assert_forbidden_for_member(&app, Method::POST, "/api/admin/users/some-id/suspend").await;
|
|
||||||
assert_forbidden_for_member(&app, Method::POST, "/api/admin/users/some-id/activate").await;
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn test_admin_user_endpoints_accept_admin_role() {
|
|
||||||
let app = admin_router();
|
|
||||||
|
|
||||||
// Admin token should pass auth (will get 503 since no DB, but not 403).
|
|
||||||
let req = Request::builder()
|
|
||||||
.uri("/api/admin/users")
|
|
||||||
.header("Authorization", "Bearer tok-admin")
|
|
||||||
.body(Body::empty())
|
|
||||||
.unwrap();
|
|
||||||
let resp = app.clone().oneshot(req).await.unwrap();
|
|
||||||
assert_ne!(
|
|
||||||
resp.status(),
|
|
||||||
StatusCode::FORBIDDEN,
|
|
||||||
"admin should not get 403"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ═══════════════════════════════════════════════════════════════════════
|
|
||||||
// DbAuthenticator Cache Bounded Tests
|
|
||||||
// ═══════════════════════════════════════════════════════════════════════
|
|
||||||
|
|
||||||
mod db_auth_cache {
|
|
||||||
use super::*;
|
|
||||||
use std::time::Instant;
|
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn test_cache_bounded_by_max_entries() {
|
|
||||||
// Access the internal cache and verify LRU eviction.
|
|
||||||
// We can't easily test through `authenticate()` since it hits the DB,
|
|
||||||
// so we test the LRU cache directly.
|
|
||||||
let cap = std::num::NonZeroUsize::new(4).unwrap(); // safety: test-only, 4 is non-zero
|
|
||||||
let cache: lru::LruCache<[u8; 32], (UserIdentity, Instant)> = lru::LruCache::new(cap);
|
|
||||||
let cache = Arc::new(tokio::sync::RwLock::new(cache));
|
|
||||||
|
|
||||||
{
|
|
||||||
let mut c = cache.write().await;
|
|
||||||
for i in 0..10u8 {
|
|
||||||
let mut hash = [0u8; 32];
|
|
||||||
hash[0] = i;
|
|
||||||
c.put(
|
|
||||||
hash,
|
|
||||||
(
|
|
||||||
UserIdentity {
|
|
||||||
user_id: format!("user-{i}"),
|
|
||||||
role: "member".to_string(),
|
|
||||||
workspace_read_scopes: vec![],
|
|
||||||
},
|
|
||||||
Instant::now(),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
// Cache must be bounded at capacity, not grown to 10.
|
|
||||||
assert_eq!(c.len(), 4, "cache should be bounded to capacity"); // safety: test assertion
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
+143
-8
@@ -662,11 +662,15 @@ pub struct RoutineInfo {
|
|||||||
pub run_count: u64,
|
pub run_count: u64,
|
||||||
pub consecutive_failures: u32,
|
pub consecutive_failures: u32,
|
||||||
pub status: String,
|
pub status: String,
|
||||||
|
pub verification_status: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl RoutineInfo {
|
impl RoutineInfo {
|
||||||
/// Convert a `Routine` to the trimmed `RoutineInfo` for list display.
|
/// Convert a `Routine` to the trimmed `RoutineInfo` for list display.
|
||||||
pub fn from_routine(r: &crate::agent::routine::Routine) -> Self {
|
pub fn from_routine(
|
||||||
|
r: &crate::agent::routine::Routine,
|
||||||
|
last_run_status: Option<crate::agent::routine::RunStatus>,
|
||||||
|
) -> Self {
|
||||||
let (trigger_type, trigger_raw, trigger_summary) = match &r.trigger {
|
let (trigger_type, trigger_raw, trigger_summary) = match &r.trigger {
|
||||||
crate::agent::routine::Trigger::Cron { schedule, timezone } => (
|
crate::agent::routine::Trigger::Cron { schedule, timezone } => (
|
||||||
"cron".to_string(),
|
"cron".to_string(),
|
||||||
@@ -710,13 +714,13 @@ impl RoutineInfo {
|
|||||||
crate::agent::routine::RoutineAction::FullJob { .. } => "full_job",
|
crate::agent::routine::RoutineAction::FullJob { .. } => "full_job",
|
||||||
};
|
};
|
||||||
|
|
||||||
let status = if !r.enabled {
|
let verification_status = crate::agent::routine::routine_verification_status(r);
|
||||||
"disabled"
|
let status = crate::agent::routine::routine_display_status_for_verification(
|
||||||
} else if r.consecutive_failures > 0 {
|
r,
|
||||||
"failing"
|
verification_status,
|
||||||
} else {
|
last_run_status,
|
||||||
"active"
|
)
|
||||||
};
|
.as_str();
|
||||||
|
|
||||||
RoutineInfo {
|
RoutineInfo {
|
||||||
id: r.id,
|
id: r.id,
|
||||||
@@ -732,6 +736,7 @@ impl RoutineInfo {
|
|||||||
run_count: r.run_count,
|
run_count: r.run_count,
|
||||||
consecutive_failures: r.consecutive_failures,
|
consecutive_failures: r.consecutive_failures,
|
||||||
status: status.to_string(),
|
status: status.to_string(),
|
||||||
|
verification_status: verification_status.as_str().to_string(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -746,6 +751,7 @@ pub struct RoutineSummaryResponse {
|
|||||||
pub total: u64,
|
pub total: u64,
|
||||||
pub enabled: u64,
|
pub enabled: u64,
|
||||||
pub disabled: u64,
|
pub disabled: u64,
|
||||||
|
pub unverified: u64,
|
||||||
pub failing: u64,
|
pub failing: u64,
|
||||||
pub runs_today: u64,
|
pub runs_today: u64,
|
||||||
}
|
}
|
||||||
@@ -767,6 +773,8 @@ pub struct RoutineDetailResponse {
|
|||||||
pub next_fire_at: Option<String>,
|
pub next_fire_at: Option<String>,
|
||||||
pub run_count: u64,
|
pub run_count: u64,
|
||||||
pub consecutive_failures: u32,
|
pub consecutive_failures: u32,
|
||||||
|
pub status: String,
|
||||||
|
pub verification_status: String,
|
||||||
pub created_at: String,
|
pub created_at: String,
|
||||||
pub recent_runs: Vec<RoutineRunInfo>,
|
pub recent_runs: Vec<RoutineRunInfo>,
|
||||||
}
|
}
|
||||||
@@ -823,6 +831,7 @@ pub struct HealthResponse {
|
|||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
use chrono::Utc;
|
||||||
|
|
||||||
// ---- WsClientMessage deserialization tests ----
|
// ---- WsClientMessage deserialization tests ----
|
||||||
|
|
||||||
@@ -1173,4 +1182,130 @@ mod tests {
|
|||||||
let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
|
let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
|
||||||
assert!(parsed.get("channel").is_none());
|
assert!(parsed.get("channel").is_none());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn make_routine_for_status_tests() -> crate::agent::routine::Routine {
|
||||||
|
crate::agent::routine::Routine {
|
||||||
|
id: Uuid::new_v4(),
|
||||||
|
name: "status-check".to_string(),
|
||||||
|
description: "routine status test".to_string(),
|
||||||
|
user_id: "test-user".to_string(),
|
||||||
|
enabled: true,
|
||||||
|
trigger: crate::agent::routine::Trigger::Manual,
|
||||||
|
action: crate::agent::routine::RoutineAction::Lightweight {
|
||||||
|
prompt: "Check status".to_string(),
|
||||||
|
context_paths: Vec::new(),
|
||||||
|
max_tokens: 256,
|
||||||
|
use_tools: false,
|
||||||
|
max_tool_rounds: 1,
|
||||||
|
},
|
||||||
|
guardrails: crate::agent::routine::RoutineGuardrails::default(),
|
||||||
|
notify: crate::agent::routine::NotifyConfig::default(),
|
||||||
|
last_run_at: None,
|
||||||
|
next_fire_at: None,
|
||||||
|
run_count: 0,
|
||||||
|
consecutive_failures: 0,
|
||||||
|
state: serde_json::json!({}),
|
||||||
|
created_at: Utc::now(),
|
||||||
|
updated_at: Utc::now(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_routine_info_marks_new_routine_unverified() {
|
||||||
|
let mut routine = make_routine_for_status_tests();
|
||||||
|
routine.state = crate::agent::routine::reset_routine_verification_state(
|
||||||
|
&routine.state,
|
||||||
|
crate::agent::routine::routine_verification_fingerprint(&routine),
|
||||||
|
);
|
||||||
|
|
||||||
|
let info = RoutineInfo::from_routine(&routine, None);
|
||||||
|
|
||||||
|
assert_eq!(info.status, "unverified");
|
||||||
|
assert_eq!(info.verification_status, "unverified");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_routine_info_preserves_verified_state_for_description_only_changes() {
|
||||||
|
let mut routine = make_routine_for_status_tests();
|
||||||
|
let fingerprint = crate::agent::routine::routine_verification_fingerprint(&routine);
|
||||||
|
routine.state = crate::agent::routine::reset_routine_verification_state(
|
||||||
|
&routine.state,
|
||||||
|
fingerprint.clone(),
|
||||||
|
);
|
||||||
|
routine.state = crate::agent::routine::apply_routine_verification_result(
|
||||||
|
&routine.state,
|
||||||
|
fingerprint,
|
||||||
|
crate::agent::routine::RunStatus::Ok,
|
||||||
|
Utc::now(),
|
||||||
|
);
|
||||||
|
routine.description = "Updated description".to_string();
|
||||||
|
|
||||||
|
let info = RoutineInfo::from_routine(&routine, Some(crate::agent::routine::RunStatus::Ok));
|
||||||
|
|
||||||
|
assert_eq!(info.status, "active");
|
||||||
|
assert_eq!(info.verification_status, "verified");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_routine_info_surfaces_running_before_unverified() {
|
||||||
|
let mut routine = make_routine_for_status_tests();
|
||||||
|
routine.state = crate::agent::routine::reset_routine_verification_state(
|
||||||
|
&routine.state,
|
||||||
|
crate::agent::routine::routine_verification_fingerprint(&routine),
|
||||||
|
);
|
||||||
|
|
||||||
|
let info =
|
||||||
|
RoutineInfo::from_routine(&routine, Some(crate::agent::routine::RunStatus::Running));
|
||||||
|
|
||||||
|
assert_eq!(info.status, "running");
|
||||||
|
assert_eq!(info.verification_status, "unverified");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_routine_info_keeps_verified_state_when_disabled() {
|
||||||
|
let mut routine = make_routine_for_status_tests();
|
||||||
|
let fingerprint = crate::agent::routine::routine_verification_fingerprint(&routine);
|
||||||
|
routine.state = crate::agent::routine::reset_routine_verification_state(
|
||||||
|
&routine.state,
|
||||||
|
fingerprint.clone(),
|
||||||
|
);
|
||||||
|
routine.state = crate::agent::routine::apply_routine_verification_result(
|
||||||
|
&routine.state,
|
||||||
|
fingerprint,
|
||||||
|
crate::agent::routine::RunStatus::Ok,
|
||||||
|
Utc::now(),
|
||||||
|
);
|
||||||
|
routine.enabled = false;
|
||||||
|
|
||||||
|
let info = RoutineInfo::from_routine(&routine, Some(crate::agent::routine::RunStatus::Ok));
|
||||||
|
|
||||||
|
assert_eq!(info.status, "disabled");
|
||||||
|
assert_eq!(info.verification_status, "verified");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_routine_info_treats_legacy_run_history_as_verified() {
|
||||||
|
let mut routine = make_routine_for_status_tests();
|
||||||
|
routine.run_count = 2;
|
||||||
|
|
||||||
|
let info = RoutineInfo::from_routine(&routine, Some(crate::agent::routine::RunStatus::Ok));
|
||||||
|
|
||||||
|
assert_eq!(info.status, "active");
|
||||||
|
assert_eq!(info.verification_status, "verified");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_routine_info_keeps_unverified_state_when_disabled() {
|
||||||
|
let mut routine = make_routine_for_status_tests();
|
||||||
|
routine.state = crate::agent::routine::reset_routine_verification_state(
|
||||||
|
&routine.state,
|
||||||
|
crate::agent::routine::routine_verification_fingerprint(&routine),
|
||||||
|
);
|
||||||
|
routine.enabled = false;
|
||||||
|
|
||||||
|
let info = RoutineInfo::from_routine(&routine, None);
|
||||||
|
|
||||||
|
assert_eq!(info.status, "disabled");
|
||||||
|
assert_eq!(info.verification_status, "unverified");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -521,6 +521,7 @@ mod tests {
|
|||||||
prompt_queue: None,
|
prompt_queue: None,
|
||||||
scheduler: None,
|
scheduler: None,
|
||||||
owner_id: "test".to_string(),
|
owner_id: "test".to_string(),
|
||||||
|
default_sender_id: "test".to_string(),
|
||||||
shutdown_tx: tokio::sync::RwLock::new(None),
|
shutdown_tx: tokio::sync::RwLock::new(None),
|
||||||
ws_tracker: Some(Arc::new(WsConnectionTracker::new())),
|
ws_tracker: Some(Arc::new(WsConnectionTracker::new())),
|
||||||
llm_provider: None,
|
llm_provider: None,
|
||||||
@@ -534,7 +535,6 @@ mod tests {
|
|||||||
routine_engine: Arc::new(tokio::sync::RwLock::new(None)),
|
routine_engine: Arc::new(tokio::sync::RwLock::new(None)),
|
||||||
startup_time: std::time::Instant::now(),
|
startup_time: std::time::Instant::now(),
|
||||||
active_config: crate::channels::web::server::ActiveConfigSnapshot::default(),
|
active_config: crate::channels::web::server::ActiveConfigSnapshot::default(),
|
||||||
secrets_store: None,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+1
-2
@@ -352,8 +352,7 @@ pub async fn run_routines_cli(
|
|||||||
.await
|
.await
|
||||||
.map_err(|e| anyhow::anyhow!("{e:#}"))?;
|
.map_err(|e| anyhow::anyhow!("{e:#}"))?;
|
||||||
|
|
||||||
let user_id =
|
let user_id = std::env::var("GATEWAY_USER_ID").unwrap_or_else(|_| "default".to_string());
|
||||||
std::env::var("IRONCLAW_OWNER_ID").unwrap_or_else(|_| "default".to_string());
|
|
||||||
run_routines_command(routines_cmd.clone(), db, &user_id).await
|
run_routines_command(routines_cmd.clone(), db, &user_id).await
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+5
-10
@@ -1,6 +1,6 @@
|
|||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
|
|
||||||
use crate::config::helpers::{parse_bool_env, parse_option_env, parse_optional_env};
|
use crate::config::helpers::{optional_env, parse_bool_env, parse_option_env, parse_optional_env};
|
||||||
use crate::error::ConfigError;
|
use crate::error::ConfigError;
|
||||||
use crate::settings::Settings;
|
use crate::settings::Settings;
|
||||||
|
|
||||||
@@ -31,13 +31,10 @@ pub struct AgentConfig {
|
|||||||
pub auto_approve_tools: bool,
|
pub auto_approve_tools: bool,
|
||||||
/// Default timezone for new sessions (IANA name, e.g. "America/New_York").
|
/// Default timezone for new sessions (IANA name, e.g. "America/New_York").
|
||||||
pub default_timezone: String,
|
pub default_timezone: String,
|
||||||
/// Maximum concurrent jobs per user. None = use global max_parallel_jobs.
|
|
||||||
pub max_jobs_per_user: Option<usize>,
|
|
||||||
/// Maximum tokens per job (0 = unlimited).
|
/// Maximum tokens per job (0 = unlimited).
|
||||||
pub max_tokens_per_job: u64,
|
pub max_tokens_per_job: u64,
|
||||||
/// Whether the deployment is multi-tenant (multiple users sharing one
|
/// Whether the deployment is multi-tenant (multiple users sharing one
|
||||||
/// instance). Detected at runtime after DB initialization, not from config.
|
/// instance). Auto-detected from GATEWAY_USER_TOKENS presence.
|
||||||
/// See app.rs startup logic.
|
|
||||||
pub multi_tenant: bool,
|
pub multi_tenant: bool,
|
||||||
/// Maximum concurrent LLM calls per user. None = use default (4).
|
/// Maximum concurrent LLM calls per user. None = use default (4).
|
||||||
pub max_llm_concurrent_per_user: Option<usize>,
|
pub max_llm_concurrent_per_user: Option<usize>,
|
||||||
@@ -65,7 +62,6 @@ impl AgentConfig {
|
|||||||
max_tool_iterations: 10,
|
max_tool_iterations: 10,
|
||||||
auto_approve_tools: true,
|
auto_approve_tools: true,
|
||||||
default_timezone: "UTC".to_string(),
|
default_timezone: "UTC".to_string(),
|
||||||
max_jobs_per_user: None,
|
|
||||||
max_tokens_per_job: 0,
|
max_tokens_per_job: 0,
|
||||||
multi_tenant: false,
|
multi_tenant: false,
|
||||||
max_llm_concurrent_per_user: None,
|
max_llm_concurrent_per_user: None,
|
||||||
@@ -126,14 +122,13 @@ impl AgentConfig {
|
|||||||
}
|
}
|
||||||
tz
|
tz
|
||||||
},
|
},
|
||||||
max_jobs_per_user: parse_option_env("MAX_JOBS_PER_USER")?,
|
|
||||||
max_tokens_per_job: parse_optional_env(
|
max_tokens_per_job: parse_optional_env(
|
||||||
"AGENT_MAX_TOKENS_PER_JOB",
|
"AGENT_MAX_TOKENS_PER_JOB",
|
||||||
settings.agent.max_tokens_per_job,
|
settings.agent.max_tokens_per_job,
|
||||||
)?,
|
)?,
|
||||||
// Multi-tenant mode is detected at runtime after DB initialization,
|
// Auto-detected from GATEWAY_USER_TOKENS presence. Not a separate
|
||||||
// not from config. See app.rs startup logic.
|
// knob — multi-tenant mode is always implied by configuring user tokens.
|
||||||
multi_tenant: false,
|
multi_tenant: optional_env("GATEWAY_USER_TOKENS")?.is_some(),
|
||||||
max_llm_concurrent_per_user: parse_option_env("TENANT_MAX_LLM_CONCURRENT")?,
|
max_llm_concurrent_per_user: parse_option_env("TENANT_MAX_LLM_CONCURRENT")?,
|
||||||
max_jobs_concurrent_per_user: parse_option_env("TENANT_MAX_JOBS_CONCURRENT")?,
|
max_jobs_concurrent_per_user: parse_option_env("TENANT_MAX_JOBS_CONCURRENT")?,
|
||||||
})
|
})
|
||||||
|
|||||||
+64
-2
@@ -1,11 +1,13 @@
|
|||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
use std::path::PathBuf;
|
use std::path::PathBuf;
|
||||||
|
|
||||||
|
use secrecy::SecretString;
|
||||||
|
use serde::Deserialize;
|
||||||
|
|
||||||
use crate::bootstrap::ironclaw_base_dir;
|
use crate::bootstrap::ironclaw_base_dir;
|
||||||
use crate::config::helpers::{optional_env, parse_bool_env, parse_optional_env};
|
use crate::config::helpers::{optional_env, parse_bool_env, parse_optional_env};
|
||||||
use crate::error::ConfigError;
|
use crate::error::ConfigError;
|
||||||
use crate::settings::Settings;
|
use crate::settings::Settings;
|
||||||
use secrecy::SecretString;
|
|
||||||
|
|
||||||
/// Channel configurations.
|
/// Channel configurations.
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
@@ -43,6 +45,7 @@ pub struct GatewayConfig {
|
|||||||
pub port: u16,
|
pub port: u16,
|
||||||
/// Bearer token for authentication. Random hex generated at startup if unset.
|
/// Bearer token for authentication. Random hex generated at startup if unset.
|
||||||
pub auth_token: Option<String>,
|
pub auth_token: Option<String>,
|
||||||
|
pub user_id: String,
|
||||||
/// Additional user scopes for workspace reads.
|
/// Additional user scopes for workspace reads.
|
||||||
///
|
///
|
||||||
/// When set, the workspace will be able to read (search, read, list) from
|
/// When set, the workspace will be able to read (search, read, list) from
|
||||||
@@ -51,6 +54,18 @@ pub struct GatewayConfig {
|
|||||||
pub workspace_read_scopes: Vec<String>,
|
pub workspace_read_scopes: Vec<String>,
|
||||||
/// Memory layer definitions (JSON in env var, or from external config).
|
/// Memory layer definitions (JSON in env var, or from external config).
|
||||||
pub memory_layers: Vec<crate::workspace::layer::MemoryLayer>,
|
pub memory_layers: Vec<crate::workspace::layer::MemoryLayer>,
|
||||||
|
/// Multi-user token map. When set, each token maps to a user identity.
|
||||||
|
/// Parsed from `GATEWAY_USER_TOKENS` (JSON string). When absent, falls back
|
||||||
|
/// to single-user mode via `auth_token` + `user_id`.
|
||||||
|
pub user_tokens: Option<HashMap<String, UserTokenConfig>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Per-user token configuration for multi-user mode.
|
||||||
|
#[derive(Debug, Clone, Deserialize)]
|
||||||
|
pub struct UserTokenConfig {
|
||||||
|
pub user_id: String,
|
||||||
|
#[serde(default)]
|
||||||
|
pub workspace_read_scopes: Vec<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Signal channel configuration (signal-cli daemon HTTP/JSON-RPC).
|
/// Signal channel configuration (signal-cli daemon HTTP/JSON-RPC).
|
||||||
@@ -117,6 +132,10 @@ impl ChannelsConfig {
|
|||||||
|
|
||||||
let gateway_enabled = parse_bool_env("GATEWAY_ENABLED", cs.gateway_enabled)?;
|
let gateway_enabled = parse_bool_env("GATEWAY_ENABLED", cs.gateway_enabled)?;
|
||||||
let gateway = if gateway_enabled {
|
let gateway = if gateway_enabled {
|
||||||
|
let user_id = optional_env("GATEWAY_USER_ID")?
|
||||||
|
.or_else(|| cs.gateway_user_id.clone())
|
||||||
|
.unwrap_or_else(|| owner_id.to_string());
|
||||||
|
|
||||||
let memory_layers: Vec<crate::workspace::layer::MemoryLayer> =
|
let memory_layers: Vec<crate::workspace::layer::MemoryLayer> =
|
||||||
match optional_env("MEMORY_LAYERS")? {
|
match optional_env("MEMORY_LAYERS")? {
|
||||||
Some(json_str) => {
|
Some(json_str) => {
|
||||||
@@ -125,7 +144,7 @@ impl ChannelsConfig {
|
|||||||
message: format!("must be valid JSON array of layer objects: {e}"),
|
message: format!("must be valid JSON array of layer objects: {e}"),
|
||||||
})?
|
})?
|
||||||
}
|
}
|
||||||
None => crate::workspace::layer::MemoryLayer::default_for_user(owner_id),
|
None => crate::workspace::layer::MemoryLayer::default_for_user(&user_id),
|
||||||
};
|
};
|
||||||
|
|
||||||
// Validate layer names and scopes
|
// Validate layer names and scopes
|
||||||
@@ -177,6 +196,41 @@ impl ChannelsConfig {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let user_tokens: Option<HashMap<String, UserTokenConfig>> =
|
||||||
|
match optional_env("GATEWAY_USER_TOKENS")? {
|
||||||
|
Some(json_str) => {
|
||||||
|
let tokens: HashMap<String, UserTokenConfig> = serde_json::from_str(
|
||||||
|
&json_str,
|
||||||
|
)
|
||||||
|
.map_err(|e| ConfigError::InvalidValue {
|
||||||
|
key: "GATEWAY_USER_TOKENS".to_string(),
|
||||||
|
message: format!(
|
||||||
|
"must be valid JSON object mapping tokens to user configs: {e}"
|
||||||
|
),
|
||||||
|
})?;
|
||||||
|
if tokens.is_empty() {
|
||||||
|
return Err(ConfigError::InvalidValue {
|
||||||
|
key: "GATEWAY_USER_TOKENS".to_string(),
|
||||||
|
message:
|
||||||
|
"token map is empty — remove the variable to use single-user mode"
|
||||||
|
.to_string(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
for (tok, cfg) in &tokens {
|
||||||
|
if cfg.user_id.trim().is_empty() {
|
||||||
|
return Err(ConfigError::InvalidValue {
|
||||||
|
key: "GATEWAY_USER_TOKENS".to_string(),
|
||||||
|
message: format!(
|
||||||
|
"token '{}...' has an empty user_id",
|
||||||
|
&tok[..tok.len().min(8)]
|
||||||
|
),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Some(tokens)
|
||||||
|
}
|
||||||
|
None => None,
|
||||||
|
};
|
||||||
let workspace_read_scopes: Vec<String> = optional_env("WORKSPACE_READ_SCOPES")?
|
let workspace_read_scopes: Vec<String> = optional_env("WORKSPACE_READ_SCOPES")?
|
||||||
.map(|s| {
|
.map(|s| {
|
||||||
s.split(',')
|
s.split(',')
|
||||||
@@ -204,8 +258,10 @@ impl ChannelsConfig {
|
|||||||
)?,
|
)?,
|
||||||
auth_token: optional_env("GATEWAY_AUTH_TOKEN")?
|
auth_token: optional_env("GATEWAY_AUTH_TOKEN")?
|
||||||
.or_else(|| cs.gateway_auth_token.clone()),
|
.or_else(|| cs.gateway_auth_token.clone()),
|
||||||
|
user_id,
|
||||||
workspace_read_scopes,
|
workspace_read_scopes,
|
||||||
memory_layers,
|
memory_layers,
|
||||||
|
user_tokens,
|
||||||
})
|
})
|
||||||
} else {
|
} else {
|
||||||
None
|
None
|
||||||
@@ -360,12 +416,15 @@ mod tests {
|
|||||||
host: "127.0.0.1".to_string(),
|
host: "127.0.0.1".to_string(),
|
||||||
port: 3000,
|
port: 3000,
|
||||||
auth_token: Some("tok-abc".to_string()),
|
auth_token: Some("tok-abc".to_string()),
|
||||||
|
user_id: "default".to_string(),
|
||||||
workspace_read_scopes: vec![],
|
workspace_read_scopes: vec![],
|
||||||
memory_layers: vec![],
|
memory_layers: vec![],
|
||||||
|
user_tokens: None,
|
||||||
};
|
};
|
||||||
assert_eq!(cfg.host, "127.0.0.1");
|
assert_eq!(cfg.host, "127.0.0.1");
|
||||||
assert_eq!(cfg.port, 3000);
|
assert_eq!(cfg.port, 3000);
|
||||||
assert_eq!(cfg.auth_token.as_deref(), Some("tok-abc"));
|
assert_eq!(cfg.auth_token.as_deref(), Some("tok-abc"));
|
||||||
|
assert_eq!(cfg.user_id, "default");
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -374,8 +433,10 @@ mod tests {
|
|||||||
host: "0.0.0.0".to_string(),
|
host: "0.0.0.0".to_string(),
|
||||||
port: 3001,
|
port: 3001,
|
||||||
auth_token: None,
|
auth_token: None,
|
||||||
|
user_id: "anon".to_string(),
|
||||||
workspace_read_scopes: vec![],
|
workspace_read_scopes: vec![],
|
||||||
memory_layers: vec![],
|
memory_layers: vec![],
|
||||||
|
user_tokens: None,
|
||||||
};
|
};
|
||||||
assert!(cfg.auth_token.is_none());
|
assert!(cfg.auth_token.is_none());
|
||||||
}
|
}
|
||||||
@@ -502,6 +563,7 @@ mod tests {
|
|||||||
assert_eq!(gateway.host, "127.0.0.3");
|
assert_eq!(gateway.host, "127.0.0.3");
|
||||||
assert_eq!(gateway.port, 9191);
|
assert_eq!(gateway.port, 9191);
|
||||||
assert_eq!(gateway.auth_token.as_deref(), Some("tok"));
|
assert_eq!(gateway.auth_token.as_deref(), Some("tok"));
|
||||||
|
assert_eq!(gateway.user_id, "owner-scope");
|
||||||
|
|
||||||
let signal = cfg.signal.expect("signal config");
|
let signal = cfg.signal.expect("signal config");
|
||||||
assert_eq!(signal.account, "+15551234567");
|
assert_eq!(signal.account, "+15551234567");
|
||||||
|
|||||||
@@ -21,8 +21,8 @@ pub struct HeartbeatConfig {
|
|||||||
pub quiet_hours_end: Option<u32>,
|
pub quiet_hours_end: Option<u32>,
|
||||||
/// Timezone for fire_at and quiet hours evaluation (IANA name).
|
/// Timezone for fire_at and quiet hours evaluation (IANA name).
|
||||||
pub timezone: Option<String>,
|
pub timezone: Option<String>,
|
||||||
/// When true, cycle through all users with routines. Set explicitly via
|
/// When true, cycle through all users with routines. Auto-detected from
|
||||||
/// HEARTBEAT_MULTI_TENANT or detected at runtime after DB initialization.
|
/// GATEWAY_USER_TOKENS or set explicitly via HEARTBEAT_MULTI_TENANT.
|
||||||
pub multi_tenant: bool,
|
pub multi_tenant: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -105,7 +105,12 @@ impl HeartbeatConfig {
|
|||||||
}
|
}
|
||||||
tz
|
tz
|
||||||
},
|
},
|
||||||
multi_tenant: parse_bool_env("HEARTBEAT_MULTI_TENANT", false)?,
|
// Auto-detect multi-tenant mode from GATEWAY_USER_TOKENS presence,
|
||||||
|
// or allow explicit override via HEARTBEAT_MULTI_TENANT.
|
||||||
|
multi_tenant: parse_bool_env(
|
||||||
|
"HEARTBEAT_MULTI_TENANT",
|
||||||
|
optional_env("GATEWAY_USER_TOKENS")?.is_some(),
|
||||||
|
)?,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,7 +12,6 @@ mod routines;
|
|||||||
mod sandbox;
|
mod sandbox;
|
||||||
mod settings;
|
mod settings;
|
||||||
mod tool_failures;
|
mod tool_failures;
|
||||||
mod users;
|
|
||||||
mod workspace;
|
mod workspace;
|
||||||
|
|
||||||
use std::path::Path;
|
use std::path::Path;
|
||||||
|
|||||||
+114
-20
@@ -4,7 +4,7 @@ use std::collections::{HashMap, HashSet};
|
|||||||
|
|
||||||
use async_trait::async_trait;
|
use async_trait::async_trait;
|
||||||
use chrono::{DateTime, Utc};
|
use chrono::{DateTime, Utc};
|
||||||
use libsql::params;
|
use libsql::{params, params_from_iter};
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
use super::{
|
use super::{
|
||||||
@@ -471,25 +471,33 @@ impl RoutineStore for LibSqlBackend {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let conn = self.connect().await?;
|
let conn = self.connect().await?;
|
||||||
|
let requested_rows = (1..=routine_ids.len())
|
||||||
// SQLite doesn't support ANY($1), so we query all latest runs and filter in memory.
|
.map(|i| format!("(?{i})"))
|
||||||
// Uses a subquery to pick only the most recent run per routine.
|
.collect::<Vec<_>>()
|
||||||
|
.join(", ");
|
||||||
|
let requested_ids = routine_ids
|
||||||
|
.iter()
|
||||||
|
.map(|id| id.to_string())
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
let sql = format!(
|
||||||
|
"WITH requested(routine_id) AS (VALUES {requested_rows})
|
||||||
|
SELECT r1.routine_id, r1.status
|
||||||
|
FROM routine_runs r1
|
||||||
|
JOIN (
|
||||||
|
SELECT rr.routine_id, MAX(rr.started_at) AS max_started_at
|
||||||
|
FROM routine_runs rr
|
||||||
|
JOIN requested req ON req.routine_id = rr.routine_id
|
||||||
|
GROUP BY rr.routine_id
|
||||||
|
) latest
|
||||||
|
ON latest.routine_id = r1.routine_id
|
||||||
|
AND latest.max_started_at = r1.started_at"
|
||||||
|
);
|
||||||
let mut rows = conn
|
let mut rows = conn
|
||||||
.query(
|
.query(&sql, params_from_iter(requested_ids))
|
||||||
"SELECT routine_id, status FROM routine_runs r1
|
|
||||||
WHERE started_at = (
|
|
||||||
SELECT MAX(started_at) FROM routine_runs r2
|
|
||||||
WHERE r2.routine_id = r1.routine_id
|
|
||||||
)
|
|
||||||
GROUP BY routine_id",
|
|
||||||
params![],
|
|
||||||
)
|
|
||||||
.await
|
.await
|
||||||
.map_err(|e| {
|
.map_err(|e| {
|
||||||
DatabaseError::Query(format!("Failed to batch get last run status: {}", e))
|
DatabaseError::Query(format!("Failed to batch get last run status: {}", e))
|
||||||
})?;
|
})?;
|
||||||
|
|
||||||
let routine_id_set: HashSet<Uuid> = routine_ids.iter().copied().collect();
|
|
||||||
let mut statuses = HashMap::new();
|
let mut statuses = HashMap::new();
|
||||||
|
|
||||||
while let Some(row) = rows
|
while let Some(row) = rows
|
||||||
@@ -501,11 +509,9 @@ impl RoutineStore for LibSqlBackend {
|
|||||||
let id = Uuid::parse_str(&id_str)
|
let id = Uuid::parse_str(&id_str)
|
||||||
.map_err(|e| DatabaseError::Query(format!("Invalid routine UUID: {}", e)))?;
|
.map_err(|e| DatabaseError::Query(format!("Invalid routine UUID: {}", e)))?;
|
||||||
|
|
||||||
if routine_id_set.contains(&id) {
|
let status_str: String = get_text(&row, 1);
|
||||||
let status_str: String = get_text(&row, 1);
|
if let std::result::Result::Ok(status) = status_str.parse::<RunStatus>() {
|
||||||
if let std::result::Result::Ok(status) = status_str.parse::<RunStatus>() {
|
statuses.insert(id, status);
|
||||||
statuses.insert(id, status);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -594,3 +600,91 @@ impl RoutineStore for LibSqlBackend {
|
|||||||
Ok(runs)
|
Ok(runs)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use crate::agent::routine::{
|
||||||
|
NotifyConfig, Routine, RoutineAction, RoutineGuardrails, RoutineRun, Trigger,
|
||||||
|
};
|
||||||
|
use crate::db::{Database, RoutineStore};
|
||||||
|
|
||||||
|
fn test_routine(user_id: &str, name: &str) -> Routine {
|
||||||
|
Routine {
|
||||||
|
id: Uuid::new_v4(),
|
||||||
|
name: name.to_string(),
|
||||||
|
description: "test routine".to_string(),
|
||||||
|
user_id: user_id.to_string(),
|
||||||
|
enabled: true,
|
||||||
|
trigger: Trigger::Manual,
|
||||||
|
action: RoutineAction::Lightweight {
|
||||||
|
prompt: "test".to_string(),
|
||||||
|
context_paths: Vec::new(),
|
||||||
|
max_tokens: 128,
|
||||||
|
use_tools: false,
|
||||||
|
max_tool_rounds: 1,
|
||||||
|
},
|
||||||
|
guardrails: RoutineGuardrails::default(),
|
||||||
|
notify: NotifyConfig::default(),
|
||||||
|
last_run_at: None,
|
||||||
|
next_fire_at: None,
|
||||||
|
run_count: 0,
|
||||||
|
consecutive_failures: 0,
|
||||||
|
state: serde_json::json!({}),
|
||||||
|
created_at: Utc::now(),
|
||||||
|
updated_at: Utc::now(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn test_run(routine_id: Uuid, status: RunStatus, started_at: DateTime<Utc>) -> RoutineRun {
|
||||||
|
RoutineRun {
|
||||||
|
id: Uuid::new_v4(),
|
||||||
|
routine_id,
|
||||||
|
trigger_type: "manual".to_string(),
|
||||||
|
trigger_detail: None,
|
||||||
|
started_at,
|
||||||
|
completed_at: None,
|
||||||
|
status,
|
||||||
|
result_summary: None,
|
||||||
|
tokens_used: None,
|
||||||
|
job_id: None,
|
||||||
|
created_at: started_at,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn batch_get_last_run_status_is_scoped_to_requested_routines() {
|
||||||
|
let dir = tempfile::tempdir().unwrap();
|
||||||
|
let db_path = dir.path().join("routine-status.db");
|
||||||
|
let backend = LibSqlBackend::new_local(&db_path).await.unwrap();
|
||||||
|
backend.run_migrations().await.unwrap();
|
||||||
|
|
||||||
|
let requested = test_routine("user-1", "requested");
|
||||||
|
let other = test_routine("user-1", "other");
|
||||||
|
backend.create_routine(&requested).await.unwrap();
|
||||||
|
backend.create_routine(&other).await.unwrap();
|
||||||
|
|
||||||
|
let now = Utc::now();
|
||||||
|
backend
|
||||||
|
.create_routine_run(&test_run(requested.id, RunStatus::Ok, now))
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
backend
|
||||||
|
.create_routine_run(&test_run(
|
||||||
|
other.id,
|
||||||
|
RunStatus::Failed,
|
||||||
|
now + chrono::Duration::seconds(1),
|
||||||
|
))
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let statuses = backend
|
||||||
|
.batch_get_last_run_status(&[requested.id])
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
assert_eq!(statuses.len(), 1);
|
||||||
|
assert_eq!(statuses.get(&requested.id), Some(&RunStatus::Ok));
|
||||||
|
assert!(!statuses.contains_key(&other.id));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,735 +0,0 @@
|
|||||||
//! UserStore implementation for LibSqlBackend.
|
|
||||||
|
|
||||||
use async_trait::async_trait;
|
|
||||||
use chrono::{DateTime, Utc};
|
|
||||||
use libsql::params;
|
|
||||||
use uuid::Uuid;
|
|
||||||
|
|
||||||
use super::{fmt_opt_ts, fmt_ts, get_opt_text, get_opt_ts, get_text, get_ts, opt_text};
|
|
||||||
use crate::db::libsql::LibSqlBackend;
|
|
||||||
use crate::db::{ApiTokenRecord, DatabaseError, UserRecord, UserStore};
|
|
||||||
|
|
||||||
fn row_to_user(row: &libsql::Row) -> Result<UserRecord, DatabaseError> {
|
|
||||||
let metadata_str = get_text(row, 9);
|
|
||||||
let metadata: serde_json::Value = serde_json::from_str(&metadata_str)
|
|
||||||
.map_err(|e| DatabaseError::Serialization(e.to_string()))?;
|
|
||||||
Ok(UserRecord {
|
|
||||||
id: get_text(row, 0),
|
|
||||||
email: get_opt_text(row, 1),
|
|
||||||
display_name: get_text(row, 2),
|
|
||||||
status: get_text(row, 3),
|
|
||||||
role: get_text(row, 4),
|
|
||||||
created_at: get_ts(row, 5),
|
|
||||||
updated_at: get_ts(row, 6),
|
|
||||||
last_login_at: get_opt_ts(row, 7),
|
|
||||||
created_by: get_opt_text(row, 8),
|
|
||||||
metadata,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
fn row_to_api_token(row: &libsql::Row) -> Result<ApiTokenRecord, DatabaseError> {
|
|
||||||
let id_str = get_text(row, 0);
|
|
||||||
let id: Uuid = id_str
|
|
||||||
.parse()
|
|
||||||
.map_err(|e| DatabaseError::Serialization(format!("invalid UUID: {e}")))?;
|
|
||||||
Ok(ApiTokenRecord {
|
|
||||||
id,
|
|
||||||
user_id: get_text(row, 1),
|
|
||||||
name: get_text(row, 2),
|
|
||||||
token_prefix: get_text(row, 3),
|
|
||||||
expires_at: get_opt_ts(row, 4),
|
|
||||||
last_used_at: get_opt_ts(row, 5),
|
|
||||||
created_at: get_ts(row, 6),
|
|
||||||
revoked_at: get_opt_ts(row, 7),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
#[async_trait]
|
|
||||||
impl UserStore for LibSqlBackend {
|
|
||||||
async fn create_user(&self, user: &UserRecord) -> Result<(), DatabaseError> {
|
|
||||||
let conn = self.connect().await?;
|
|
||||||
let metadata_json = serde_json::to_string(&user.metadata)
|
|
||||||
.map_err(|e| DatabaseError::Serialization(e.to_string()))?;
|
|
||||||
|
|
||||||
conn.execute(
|
|
||||||
r#"
|
|
||||||
INSERT INTO users (id, email, display_name, status, role, created_at, updated_at, last_login_at, created_by, metadata)
|
|
||||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10)
|
|
||||||
"#,
|
|
||||||
params![
|
|
||||||
user.id.as_str(),
|
|
||||||
opt_text(user.email.as_deref()),
|
|
||||||
user.display_name.as_str(),
|
|
||||||
user.status.as_str(),
|
|
||||||
user.role.as_str(),
|
|
||||||
fmt_ts(&user.created_at),
|
|
||||||
fmt_ts(&user.updated_at),
|
|
||||||
fmt_opt_ts(&user.last_login_at),
|
|
||||||
opt_text(user.created_by.as_deref()),
|
|
||||||
metadata_json,
|
|
||||||
],
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
.map_err(|e| DatabaseError::Query(e.to_string()))?;
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn get_user(&self, id: &str) -> Result<Option<UserRecord>, DatabaseError> {
|
|
||||||
let conn = self.connect().await?;
|
|
||||||
let mut rows = conn
|
|
||||||
.query(
|
|
||||||
r#"
|
|
||||||
SELECT id, email, display_name, status, role, created_at, updated_at,
|
|
||||||
last_login_at, created_by, metadata
|
|
||||||
FROM users WHERE id = ?1
|
|
||||||
"#,
|
|
||||||
params![id],
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
.map_err(|e| DatabaseError::Query(e.to_string()))?;
|
|
||||||
|
|
||||||
match rows
|
|
||||||
.next()
|
|
||||||
.await
|
|
||||||
.map_err(|e| DatabaseError::Query(e.to_string()))?
|
|
||||||
{
|
|
||||||
Some(row) => Ok(Some(row_to_user(&row)?)),
|
|
||||||
None => Ok(None),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn get_user_by_email(&self, email: &str) -> Result<Option<UserRecord>, DatabaseError> {
|
|
||||||
let conn = self.connect().await?;
|
|
||||||
let mut rows = conn
|
|
||||||
.query(
|
|
||||||
r#"
|
|
||||||
SELECT id, email, display_name, status, role, created_at, updated_at,
|
|
||||||
last_login_at, created_by, metadata
|
|
||||||
FROM users WHERE email = ?1
|
|
||||||
"#,
|
|
||||||
params![email],
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
.map_err(|e| DatabaseError::Query(e.to_string()))?;
|
|
||||||
|
|
||||||
match rows
|
|
||||||
.next()
|
|
||||||
.await
|
|
||||||
.map_err(|e| DatabaseError::Query(e.to_string()))?
|
|
||||||
{
|
|
||||||
Some(row) => Ok(Some(row_to_user(&row)?)),
|
|
||||||
None => Ok(None),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn list_users(&self, status: Option<&str>) -> Result<Vec<UserRecord>, DatabaseError> {
|
|
||||||
let conn = self.connect().await?;
|
|
||||||
let mut users = Vec::new();
|
|
||||||
|
|
||||||
let mut rows = if let Some(status) = status {
|
|
||||||
conn.query(
|
|
||||||
r#"
|
|
||||||
SELECT id, email, display_name, status, role, created_at, updated_at,
|
|
||||||
last_login_at, created_by, metadata
|
|
||||||
FROM users WHERE status = ?1
|
|
||||||
ORDER BY created_at DESC
|
|
||||||
"#,
|
|
||||||
params![status],
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
.map_err(|e| DatabaseError::Query(e.to_string()))?
|
|
||||||
} else {
|
|
||||||
conn.query(
|
|
||||||
r#"
|
|
||||||
SELECT id, email, display_name, status, role, created_at, updated_at,
|
|
||||||
last_login_at, created_by, metadata
|
|
||||||
FROM users
|
|
||||||
ORDER BY created_at DESC
|
|
||||||
"#,
|
|
||||||
(),
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
.map_err(|e| DatabaseError::Query(e.to_string()))?
|
|
||||||
};
|
|
||||||
|
|
||||||
while let Some(row) = rows
|
|
||||||
.next()
|
|
||||||
.await
|
|
||||||
.map_err(|e| DatabaseError::Query(e.to_string()))?
|
|
||||||
{
|
|
||||||
users.push(row_to_user(&row)?);
|
|
||||||
}
|
|
||||||
Ok(users)
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn update_user_status(&self, id: &str, status: &str) -> Result<(), DatabaseError> {
|
|
||||||
let conn = self.connect().await?;
|
|
||||||
let now = fmt_ts(&Utc::now());
|
|
||||||
conn.execute(
|
|
||||||
"UPDATE users SET status = ?2, updated_at = ?3 WHERE id = ?1",
|
|
||||||
params![id, status, now],
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
.map_err(|e| DatabaseError::Query(e.to_string()))?;
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn update_user_profile(
|
|
||||||
&self,
|
|
||||||
id: &str,
|
|
||||||
display_name: &str,
|
|
||||||
metadata: &serde_json::Value,
|
|
||||||
) -> Result<(), DatabaseError> {
|
|
||||||
let conn = self.connect().await?;
|
|
||||||
let now = fmt_ts(&Utc::now());
|
|
||||||
let metadata_json = serde_json::to_string(metadata)
|
|
||||||
.map_err(|e| DatabaseError::Serialization(e.to_string()))?;
|
|
||||||
conn.execute(
|
|
||||||
"UPDATE users SET display_name = ?2, metadata = ?3, updated_at = ?4 WHERE id = ?1",
|
|
||||||
params![id, display_name, metadata_json, now],
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
.map_err(|e| DatabaseError::Query(e.to_string()))?;
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn record_login(&self, id: &str) -> Result<(), DatabaseError> {
|
|
||||||
let conn = self.connect().await?;
|
|
||||||
let now = fmt_ts(&Utc::now());
|
|
||||||
conn.execute(
|
|
||||||
"UPDATE users SET last_login_at = ?2, updated_at = ?2 WHERE id = ?1",
|
|
||||||
params![id, now],
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
.map_err(|e| DatabaseError::Query(e.to_string()))?;
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn create_api_token(
|
|
||||||
&self,
|
|
||||||
user_id: &str,
|
|
||||||
name: &str,
|
|
||||||
token_hash: &[u8; 32],
|
|
||||||
token_prefix: &str,
|
|
||||||
expires_at: Option<DateTime<Utc>>,
|
|
||||||
) -> Result<ApiTokenRecord, DatabaseError> {
|
|
||||||
let conn = self.connect().await?;
|
|
||||||
let id = Uuid::new_v4();
|
|
||||||
let now = Utc::now();
|
|
||||||
|
|
||||||
conn.execute(
|
|
||||||
r#"
|
|
||||||
INSERT INTO api_tokens (id, user_id, token_hash, token_prefix, name, expires_at, created_at)
|
|
||||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)
|
|
||||||
"#,
|
|
||||||
params![
|
|
||||||
id.to_string(),
|
|
||||||
user_id,
|
|
||||||
libsql::Value::Blob(token_hash.to_vec()),
|
|
||||||
token_prefix,
|
|
||||||
name,
|
|
||||||
fmt_opt_ts(&expires_at),
|
|
||||||
fmt_ts(&now),
|
|
||||||
],
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
.map_err(|e| DatabaseError::Query(e.to_string()))?;
|
|
||||||
|
|
||||||
Ok(ApiTokenRecord {
|
|
||||||
id,
|
|
||||||
user_id: user_id.to_string(),
|
|
||||||
name: name.to_string(),
|
|
||||||
token_prefix: token_prefix.to_string(),
|
|
||||||
expires_at,
|
|
||||||
last_used_at: None,
|
|
||||||
created_at: now,
|
|
||||||
revoked_at: None,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn list_api_tokens(&self, user_id: &str) -> Result<Vec<ApiTokenRecord>, DatabaseError> {
|
|
||||||
let conn = self.connect().await?;
|
|
||||||
let mut rows = conn
|
|
||||||
.query(
|
|
||||||
r#"
|
|
||||||
SELECT id, user_id, name, token_prefix, expires_at, last_used_at, created_at, revoked_at
|
|
||||||
FROM api_tokens WHERE user_id = ?1
|
|
||||||
ORDER BY created_at DESC
|
|
||||||
"#,
|
|
||||||
params![user_id],
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
.map_err(|e| DatabaseError::Query(e.to_string()))?;
|
|
||||||
|
|
||||||
let mut tokens = Vec::new();
|
|
||||||
while let Some(row) = rows
|
|
||||||
.next()
|
|
||||||
.await
|
|
||||||
.map_err(|e| DatabaseError::Query(e.to_string()))?
|
|
||||||
{
|
|
||||||
tokens.push(row_to_api_token(&row)?);
|
|
||||||
}
|
|
||||||
Ok(tokens)
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn revoke_api_token(&self, token_id: Uuid, user_id: &str) -> Result<bool, DatabaseError> {
|
|
||||||
let conn = self.connect().await?;
|
|
||||||
let now = fmt_ts(&Utc::now());
|
|
||||||
let rows_affected = conn
|
|
||||||
.execute(
|
|
||||||
r#"
|
|
||||||
UPDATE api_tokens SET revoked_at = ?3
|
|
||||||
WHERE id = ?1 AND user_id = ?2 AND revoked_at IS NULL
|
|
||||||
"#,
|
|
||||||
params![token_id.to_string(), user_id, now],
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
.map_err(|e| DatabaseError::Query(e.to_string()))?;
|
|
||||||
Ok(rows_affected > 0)
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn authenticate_token(
|
|
||||||
&self,
|
|
||||||
token_hash: &[u8; 32],
|
|
||||||
) -> Result<Option<(ApiTokenRecord, UserRecord)>, DatabaseError> {
|
|
||||||
let conn = self.connect().await?;
|
|
||||||
let now = fmt_ts(&Utc::now());
|
|
||||||
|
|
||||||
let mut rows = conn
|
|
||||||
.query(
|
|
||||||
r#"
|
|
||||||
SELECT
|
|
||||||
t.id, t.user_id, t.name, t.token_prefix, t.expires_at,
|
|
||||||
t.last_used_at, t.created_at, t.revoked_at,
|
|
||||||
u.id, u.email, u.display_name, u.status, u.role, u.created_at,
|
|
||||||
u.updated_at, u.last_login_at, u.created_by, u.metadata
|
|
||||||
FROM api_tokens t
|
|
||||||
JOIN users u ON u.id = t.user_id
|
|
||||||
WHERE t.token_hash = ?1
|
|
||||||
AND t.revoked_at IS NULL
|
|
||||||
AND (t.expires_at IS NULL OR t.expires_at > ?2)
|
|
||||||
AND u.status = 'active'
|
|
||||||
"#,
|
|
||||||
params![libsql::Value::Blob(token_hash.to_vec()), now],
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
.map_err(|e| DatabaseError::Query(e.to_string()))?;
|
|
||||||
|
|
||||||
match rows
|
|
||||||
.next()
|
|
||||||
.await
|
|
||||||
.map_err(|e| DatabaseError::Query(e.to_string()))?
|
|
||||||
{
|
|
||||||
Some(row) => {
|
|
||||||
let id_str = get_text(&row, 0);
|
|
||||||
let token_id: Uuid = id_str
|
|
||||||
.parse()
|
|
||||||
.map_err(|e| DatabaseError::Serialization(format!("invalid UUID: {e}")))?;
|
|
||||||
let token = ApiTokenRecord {
|
|
||||||
id: token_id,
|
|
||||||
user_id: get_text(&row, 1),
|
|
||||||
name: get_text(&row, 2),
|
|
||||||
token_prefix: get_text(&row, 3),
|
|
||||||
expires_at: get_opt_ts(&row, 4),
|
|
||||||
last_used_at: get_opt_ts(&row, 5),
|
|
||||||
created_at: get_ts(&row, 6),
|
|
||||||
revoked_at: get_opt_ts(&row, 7),
|
|
||||||
};
|
|
||||||
|
|
||||||
let metadata_str = get_text(&row, 17);
|
|
||||||
let metadata: serde_json::Value = serde_json::from_str(&metadata_str)
|
|
||||||
.map_err(|e| DatabaseError::Serialization(e.to_string()))?;
|
|
||||||
|
|
||||||
let user = UserRecord {
|
|
||||||
id: get_text(&row, 8),
|
|
||||||
email: get_opt_text(&row, 9),
|
|
||||||
display_name: get_text(&row, 10),
|
|
||||||
status: get_text(&row, 11),
|
|
||||||
role: get_text(&row, 12),
|
|
||||||
created_at: get_ts(&row, 13),
|
|
||||||
updated_at: get_ts(&row, 14),
|
|
||||||
last_login_at: get_opt_ts(&row, 15),
|
|
||||||
created_by: get_opt_text(&row, 16),
|
|
||||||
metadata,
|
|
||||||
};
|
|
||||||
|
|
||||||
Ok(Some((token, user)))
|
|
||||||
}
|
|
||||||
None => Ok(None),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn record_token_usage(&self, token_id: Uuid) -> Result<(), DatabaseError> {
|
|
||||||
let conn = self.connect().await?;
|
|
||||||
let now = fmt_ts(&Utc::now());
|
|
||||||
conn.execute(
|
|
||||||
"UPDATE api_tokens SET last_used_at = ?2 WHERE id = ?1",
|
|
||||||
params![token_id.to_string(), now],
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
.map_err(|e| DatabaseError::Query(e.to_string()))?;
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn has_any_users(&self) -> Result<bool, DatabaseError> {
|
|
||||||
let conn = self.connect().await?;
|
|
||||||
let mut rows = conn
|
|
||||||
.query("SELECT 1 FROM users LIMIT 1", ())
|
|
||||||
.await
|
|
||||||
.map_err(|e| DatabaseError::Query(e.to_string()))?;
|
|
||||||
|
|
||||||
let has_users = rows
|
|
||||||
.next()
|
|
||||||
.await
|
|
||||||
.map_err(|e| DatabaseError::Query(e.to_string()))?
|
|
||||||
.is_some();
|
|
||||||
Ok(has_users)
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn delete_user(&self, id: &str) -> Result<bool, DatabaseError> {
|
|
||||||
let conn = self.connect().await?;
|
|
||||||
// Delete from child tables first to avoid FK violations.
|
|
||||||
// agent_jobs cascades to job_actions, llm_calls, estimation_snapshots
|
|
||||||
// conversations cascades to conversation_messages
|
|
||||||
// memory_documents cascades to memory_chunks
|
|
||||||
// routines cascades to routine_runs
|
|
||||||
for table in &[
|
|
||||||
"settings",
|
|
||||||
"heartbeat_state",
|
|
||||||
"tool_rate_limit_state",
|
|
||||||
"secret_usage_log",
|
|
||||||
"leak_detection_events",
|
|
||||||
"secrets",
|
|
||||||
"wasm_tools",
|
|
||||||
"routines",
|
|
||||||
"memory_documents",
|
|
||||||
"conversations",
|
|
||||||
"api_tokens",
|
|
||||||
] {
|
|
||||||
conn.execute(
|
|
||||||
&format!("DELETE FROM {} WHERE user_id = ?1", table),
|
|
||||||
params![id],
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
.map_err(|e| DatabaseError::Query(e.to_string()))?;
|
|
||||||
}
|
|
||||||
// job_events references agent_jobs(id) without CASCADE — delete via subquery.
|
|
||||||
conn.execute(
|
|
||||||
"DELETE FROM job_events WHERE job_id IN (SELECT id FROM agent_jobs WHERE user_id = ?1)",
|
|
||||||
params![id],
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
.map_err(|e| DatabaseError::Query(e.to_string()))?;
|
|
||||||
conn.execute("DELETE FROM agent_jobs WHERE user_id = ?1", params![id])
|
|
||||||
.await
|
|
||||||
.map_err(|e| DatabaseError::Query(e.to_string()))?;
|
|
||||||
// Nullify self-referencing created_by before deleting the user
|
|
||||||
conn.execute(
|
|
||||||
"UPDATE users SET created_by = NULL WHERE created_by = ?1",
|
|
||||||
params![id],
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
.map_err(|e| DatabaseError::Query(e.to_string()))?;
|
|
||||||
let rows = conn
|
|
||||||
.execute("DELETE FROM users WHERE id = ?1", params![id])
|
|
||||||
.await
|
|
||||||
.map_err(|e| DatabaseError::Query(e.to_string()))?;
|
|
||||||
Ok(rows > 0)
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn user_usage_stats(
|
|
||||||
&self,
|
|
||||||
user_id: Option<&str>,
|
|
||||||
since: DateTime<Utc>,
|
|
||||||
) -> Result<Vec<crate::db::UserUsageStats>, DatabaseError> {
|
|
||||||
let conn = self.connect().await?;
|
|
||||||
let since_str = fmt_ts(&since);
|
|
||||||
let mut rows = if let Some(uid) = user_id {
|
|
||||||
conn.query(
|
|
||||||
r#"
|
|
||||||
SELECT j.user_id, l.model, COUNT(*) as call_count,
|
|
||||||
COALESCE(SUM(l.input_tokens), 0) as input_tokens,
|
|
||||||
COALESCE(SUM(l.output_tokens), 0) as output_tokens,
|
|
||||||
COALESCE(SUM(l.cost), 0) as total_cost
|
|
||||||
FROM llm_calls l
|
|
||||||
JOIN agent_jobs j ON l.job_id = j.id
|
|
||||||
WHERE l.created_at >= ?1
|
|
||||||
AND j.user_id = ?2
|
|
||||||
GROUP BY j.user_id, l.model
|
|
||||||
ORDER BY total_cost DESC
|
|
||||||
"#,
|
|
||||||
params![since_str, uid],
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
.map_err(|e| DatabaseError::Query(e.to_string()))?
|
|
||||||
} else {
|
|
||||||
conn.query(
|
|
||||||
r#"
|
|
||||||
SELECT j.user_id, l.model, COUNT(*) as call_count,
|
|
||||||
COALESCE(SUM(l.input_tokens), 0) as input_tokens,
|
|
||||||
COALESCE(SUM(l.output_tokens), 0) as output_tokens,
|
|
||||||
COALESCE(SUM(l.cost), 0) as total_cost
|
|
||||||
FROM llm_calls l
|
|
||||||
JOIN agent_jobs j ON l.job_id = j.id
|
|
||||||
WHERE l.created_at >= ?1
|
|
||||||
GROUP BY j.user_id, l.model
|
|
||||||
ORDER BY total_cost DESC
|
|
||||||
"#,
|
|
||||||
params![since_str],
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
.map_err(|e| DatabaseError::Query(e.to_string()))?
|
|
||||||
};
|
|
||||||
let mut stats = Vec::new();
|
|
||||||
while let Some(row) = rows
|
|
||||||
.next()
|
|
||||||
.await
|
|
||||||
.map_err(|e| DatabaseError::Query(e.to_string()))?
|
|
||||||
{
|
|
||||||
let cost_str = get_text(&row, 5);
|
|
||||||
let total_cost = rust_decimal::Decimal::from_str_exact(&cost_str).unwrap_or_default();
|
|
||||||
stats.push(crate::db::UserUsageStats {
|
|
||||||
user_id: get_text(&row, 0),
|
|
||||||
model: get_text(&row, 1),
|
|
||||||
call_count: row
|
|
||||||
.get::<i64>(2)
|
|
||||||
.map_err(|e| DatabaseError::Query(e.to_string()))?,
|
|
||||||
input_tokens: row
|
|
||||||
.get::<i64>(3)
|
|
||||||
.map_err(|e| DatabaseError::Query(e.to_string()))?,
|
|
||||||
output_tokens: row
|
|
||||||
.get::<i64>(4)
|
|
||||||
.map_err(|e| DatabaseError::Query(e.to_string()))?,
|
|
||||||
total_cost,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
Ok(stats)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use super::*;
|
|
||||||
use crate::db::libsql::LibSqlBackend;
|
|
||||||
use crate::db::{Database, UserStore};
|
|
||||||
use sha2::{Digest, Sha256};
|
|
||||||
|
|
||||||
fn hash(s: &str) -> [u8; 32] {
|
|
||||||
let mut h = Sha256::new();
|
|
||||||
h.update(s.as_bytes());
|
|
||||||
h.finalize().into()
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn setup() -> (LibSqlBackend, tempfile::TempDir) {
|
|
||||||
let dir = tempfile::tempdir().unwrap();
|
|
||||||
let db_path = dir.path().join("test_users.db");
|
|
||||||
let db = LibSqlBackend::new_local(&db_path).await.unwrap();
|
|
||||||
db.run_migrations().await.unwrap();
|
|
||||||
(db, dir) // keep dir alive so the DB file isn't deleted
|
|
||||||
}
|
|
||||||
|
|
||||||
fn test_user(id: &str) -> UserRecord {
|
|
||||||
UserRecord {
|
|
||||||
id: id.to_string(),
|
|
||||||
email: Some(format!("{}@test.com", id)),
|
|
||||||
display_name: id.to_string(),
|
|
||||||
status: "active".to_string(),
|
|
||||||
role: "member".to_string(),
|
|
||||||
created_at: Utc::now(),
|
|
||||||
updated_at: Utc::now(),
|
|
||||||
last_login_at: None,
|
|
||||||
created_by: None,
|
|
||||||
metadata: serde_json::json!({}),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn test_has_any_users_empty() {
|
|
||||||
let (db, _dir) = setup().await;
|
|
||||||
assert!(!db.has_any_users().await.unwrap());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn test_create_and_get_user() {
|
|
||||||
let (db, _dir) = setup().await;
|
|
||||||
let user = test_user("alice");
|
|
||||||
db.create_user(&user).await.unwrap();
|
|
||||||
|
|
||||||
assert!(db.has_any_users().await.unwrap());
|
|
||||||
|
|
||||||
let found = db.get_user("alice").await.unwrap().unwrap();
|
|
||||||
assert_eq!(found.id, "alice");
|
|
||||||
assert_eq!(found.email, Some("[email protected]".to_string()));
|
|
||||||
assert_eq!(found.status, "active");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn test_get_user_by_email() {
|
|
||||||
let (db, _dir) = setup().await;
|
|
||||||
db.create_user(&test_user("bob")).await.unwrap();
|
|
||||||
|
|
||||||
let found = db.get_user_by_email("[email protected]").await.unwrap();
|
|
||||||
assert!(found.is_some());
|
|
||||||
assert_eq!(found.unwrap().id, "bob");
|
|
||||||
|
|
||||||
assert!(
|
|
||||||
db.get_user_by_email("[email protected]")
|
|
||||||
.await
|
|
||||||
.unwrap()
|
|
||||||
.is_none()
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn test_list_users_with_status_filter() {
|
|
||||||
let (db, _dir) = setup().await;
|
|
||||||
db.create_user(&test_user("alice")).await.unwrap();
|
|
||||||
db.create_user(&test_user("bob")).await.unwrap();
|
|
||||||
db.update_user_status("bob", "suspended").await.unwrap();
|
|
||||||
|
|
||||||
let all = db.list_users(None).await.unwrap();
|
|
||||||
assert_eq!(all.len(), 2);
|
|
||||||
|
|
||||||
let active = db.list_users(Some("active")).await.unwrap();
|
|
||||||
assert_eq!(active.len(), 1);
|
|
||||||
assert_eq!(active[0].id, "alice");
|
|
||||||
|
|
||||||
let suspended = db.list_users(Some("suspended")).await.unwrap();
|
|
||||||
assert_eq!(suspended.len(), 1);
|
|
||||||
assert_eq!(suspended[0].id, "bob");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn test_update_user_profile() {
|
|
||||||
let (db, _dir) = setup().await;
|
|
||||||
db.create_user(&test_user("alice")).await.unwrap();
|
|
||||||
|
|
||||||
let meta = serde_json::json!({"role": "admin"});
|
|
||||||
db.update_user_profile("alice", "Alice Smith", &meta)
|
|
||||||
.await
|
|
||||||
.unwrap();
|
|
||||||
|
|
||||||
let user = db.get_user("alice").await.unwrap().unwrap();
|
|
||||||
assert_eq!(user.display_name, "Alice Smith");
|
|
||||||
assert_eq!(user.metadata["role"], "admin");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn test_token_lifecycle_create_authenticate_revoke() {
|
|
||||||
let (db, _dir) = setup().await;
|
|
||||||
db.create_user(&test_user("alice")).await.unwrap();
|
|
||||||
|
|
||||||
// Create token
|
|
||||||
let token_hash = hash("secret-token-123");
|
|
||||||
let record = db
|
|
||||||
.create_api_token("alice", "laptop", &token_hash, "secret-t", None)
|
|
||||||
.await
|
|
||||||
.unwrap();
|
|
||||||
assert_eq!(record.user_id, "alice");
|
|
||||||
assert_eq!(record.name, "laptop");
|
|
||||||
assert_eq!(record.token_prefix, "secret-t");
|
|
||||||
|
|
||||||
// Authenticate
|
|
||||||
let (tok, user) = db.authenticate_token(&token_hash).await.unwrap().unwrap();
|
|
||||||
assert_eq!(tok.id, record.id);
|
|
||||||
assert_eq!(user.id, "alice");
|
|
||||||
|
|
||||||
// List tokens
|
|
||||||
let tokens = db.list_api_tokens("alice").await.unwrap();
|
|
||||||
assert_eq!(tokens.len(), 1);
|
|
||||||
|
|
||||||
// Revoke
|
|
||||||
assert!(db.revoke_api_token(record.id, "alice").await.unwrap());
|
|
||||||
|
|
||||||
// Auth should fail after revoke
|
|
||||||
assert!(db.authenticate_token(&token_hash).await.unwrap().is_none());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn test_token_auth_fails_for_suspended_user() {
|
|
||||||
let (db, _dir) = setup().await;
|
|
||||||
db.create_user(&test_user("alice")).await.unwrap();
|
|
||||||
|
|
||||||
let token_hash = hash("token-abc");
|
|
||||||
db.create_api_token("alice", "test", &token_hash, "token-ab", None)
|
|
||||||
.await
|
|
||||||
.unwrap();
|
|
||||||
|
|
||||||
// Auth works while active
|
|
||||||
assert!(db.authenticate_token(&token_hash).await.unwrap().is_some());
|
|
||||||
|
|
||||||
// Suspend user
|
|
||||||
db.update_user_status("alice", "suspended").await.unwrap();
|
|
||||||
|
|
||||||
// Auth should fail
|
|
||||||
assert!(db.authenticate_token(&token_hash).await.unwrap().is_none());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn test_token_revoke_wrong_user_returns_false() {
|
|
||||||
let (db, _dir) = setup().await;
|
|
||||||
db.create_user(&test_user("alice")).await.unwrap();
|
|
||||||
db.create_user(&test_user("bob")).await.unwrap();
|
|
||||||
|
|
||||||
let token_hash = hash("alice-token");
|
|
||||||
let record = db
|
|
||||||
.create_api_token("alice", "test", &token_hash, "alice-to", None)
|
|
||||||
.await
|
|
||||||
.unwrap();
|
|
||||||
|
|
||||||
// Bob can't revoke Alice's token
|
|
||||||
assert!(!db.revoke_api_token(record.id, "bob").await.unwrap());
|
|
||||||
|
|
||||||
// Alice can
|
|
||||||
assert!(db.revoke_api_token(record.id, "alice").await.unwrap());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn test_record_login_and_token_usage() {
|
|
||||||
let (db, _dir) = setup().await;
|
|
||||||
db.create_user(&test_user("alice")).await.unwrap();
|
|
||||||
|
|
||||||
let token_hash = hash("tok");
|
|
||||||
let record = db
|
|
||||||
.create_api_token("alice", "test", &token_hash, "tok", None)
|
|
||||||
.await
|
|
||||||
.unwrap();
|
|
||||||
|
|
||||||
// Record usage
|
|
||||||
db.record_token_usage(record.id).await.unwrap();
|
|
||||||
db.record_login("alice").await.unwrap();
|
|
||||||
|
|
||||||
// Verify timestamps updated
|
|
||||||
let user = db.get_user("alice").await.unwrap().unwrap();
|
|
||||||
assert!(user.last_login_at.is_some());
|
|
||||||
|
|
||||||
let tokens = db.list_api_tokens("alice").await.unwrap();
|
|
||||||
assert!(tokens[0].last_used_at.is_some());
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn test_delete_user_removes_api_tokens() {
|
|
||||||
let (db, _dir) = setup().await;
|
|
||||||
db.create_user(&test_user("alice")).await.unwrap();
|
|
||||||
|
|
||||||
let token_hash = hash("alice-tok");
|
|
||||||
db.create_api_token("alice", "primary", &token_hash, "alice-to", None)
|
|
||||||
.await
|
|
||||||
.unwrap();
|
|
||||||
|
|
||||||
// Verify token exists before deletion.
|
|
||||||
let tokens = db.list_api_tokens("alice").await.unwrap();
|
|
||||||
assert_eq!(tokens.len(), 1);
|
|
||||||
|
|
||||||
// Delete user — should also remove their api_tokens.
|
|
||||||
assert!(db.delete_user("alice").await.unwrap());
|
|
||||||
|
|
||||||
// api_tokens must be gone (not orphaned).
|
|
||||||
let tokens = db.list_api_tokens("alice").await.unwrap();
|
|
||||||
assert!(
|
|
||||||
tokens.is_empty(),
|
|
||||||
"expected api_tokens to be deleted with user, found {}",
|
|
||||||
tokens.len()
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -579,36 +579,6 @@ INSERT OR IGNORE INTO leak_detection_patterns (id, name, pattern, severity, acti
|
|||||||
('550e8400-e29b-41d4-a716-446655440011', 'mailchimp_api_key', '[a-f0-9]{32}-us[0-9]{1,2}', 'medium', 'block', 1, strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
|
('550e8400-e29b-41d4-a716-446655440011', 'mailchimp_api_key', '[a-f0-9]{32}-us[0-9]{1,2}', 'medium', 'block', 1, strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
|
||||||
('550e8400-e29b-41d4-a716-446655440012', 'high_entropy_hex', '(?<![a-fA-F0-9])[a-fA-F0-9]{64}(?![a-fA-F0-9])', 'medium', 'warn', 1, strftime('%Y-%m-%dT%H:%M:%fZ', 'now'));
|
('550e8400-e29b-41d4-a716-446655440012', 'high_entropy_hex', '(?<![a-fA-F0-9])[a-fA-F0-9]{64}(?![a-fA-F0-9])', 'medium', 'warn', 1, strftime('%Y-%m-%dT%H:%M:%fZ', 'now'));
|
||||||
|
|
||||||
|
|
||||||
-- ==================== User management (V14) ====================
|
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS users (
|
|
||||||
id TEXT PRIMARY KEY,
|
|
||||||
email TEXT UNIQUE,
|
|
||||||
display_name TEXT NOT NULL,
|
|
||||||
status TEXT NOT NULL DEFAULT 'active',
|
|
||||||
role TEXT NOT NULL DEFAULT 'member',
|
|
||||||
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
|
|
||||||
updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
|
|
||||||
last_login_at TEXT,
|
|
||||||
created_by TEXT,
|
|
||||||
metadata TEXT NOT NULL DEFAULT '{}'
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS api_tokens (
|
|
||||||
id TEXT PRIMARY KEY,
|
|
||||||
user_id TEXT NOT NULL,
|
|
||||||
token_hash BLOB NOT NULL,
|
|
||||||
token_prefix TEXT NOT NULL,
|
|
||||||
name TEXT NOT NULL,
|
|
||||||
expires_at TEXT,
|
|
||||||
last_used_at TEXT,
|
|
||||||
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
|
|
||||||
revoked_at TEXT
|
|
||||||
);
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_api_tokens_user ON api_tokens(user_id);
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_api_tokens_hash ON api_tokens(token_hash);
|
|
||||||
|
|
||||||
"#;
|
"#;
|
||||||
|
|
||||||
/// Incremental migrations applied after the base schema.
|
/// Incremental migrations applied after the base schema.
|
||||||
@@ -753,38 +723,6 @@ CREATE INDEX IF NOT EXISTS idx_routines_event_triggers
|
|||||||
WHERE enabled = 1 AND trigger_type IN ('event', 'system_event');
|
WHERE enabled = 1 AND trigger_type IN ('event', 'system_event');
|
||||||
|
|
||||||
PRAGMA foreign_keys=ON;
|
PRAGMA foreign_keys=ON;
|
||||||
"#,
|
|
||||||
),
|
|
||||||
(
|
|
||||||
14,
|
|
||||||
"users",
|
|
||||||
r#"
|
|
||||||
CREATE TABLE IF NOT EXISTS users (
|
|
||||||
id TEXT PRIMARY KEY,
|
|
||||||
email TEXT UNIQUE,
|
|
||||||
display_name TEXT NOT NULL,
|
|
||||||
status TEXT NOT NULL DEFAULT 'active',
|
|
||||||
role TEXT NOT NULL DEFAULT 'member',
|
|
||||||
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
|
|
||||||
updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
|
|
||||||
last_login_at TEXT,
|
|
||||||
created_by TEXT,
|
|
||||||
metadata TEXT NOT NULL DEFAULT '{}'
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS api_tokens (
|
|
||||||
id TEXT PRIMARY KEY,
|
|
||||||
user_id TEXT NOT NULL,
|
|
||||||
token_hash BLOB NOT NULL,
|
|
||||||
token_prefix TEXT NOT NULL,
|
|
||||||
name TEXT NOT NULL,
|
|
||||||
expires_at TEXT,
|
|
||||||
last_used_at TEXT,
|
|
||||||
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
|
|
||||||
revoked_at TEXT
|
|
||||||
);
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_api_tokens_user ON api_tokens(user_id);
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_api_tokens_hash ON api_tokens(token_hash);
|
|
||||||
"#,
|
"#,
|
||||||
),
|
),
|
||||||
];
|
];
|
||||||
|
|||||||
-113
@@ -309,43 +309,6 @@ async fn validate_postgres(pool: &deadpool_postgres::Pool) -> Result<(), Databas
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
// ==================== User management record types ====================
|
|
||||||
|
|
||||||
/// A registered user.
|
|
||||||
#[derive(Debug, Clone)]
|
|
||||||
pub struct UserRecord {
|
|
||||||
/// User identifier (string, matches existing `user_id` throughout the codebase).
|
|
||||||
pub id: String,
|
|
||||||
pub email: Option<String>,
|
|
||||||
pub display_name: String,
|
|
||||||
/// `active`, `suspended`, or `deactivated`.
|
|
||||||
pub status: String,
|
|
||||||
/// `admin` or `member`.
|
|
||||||
pub role: String,
|
|
||||||
pub created_at: DateTime<Utc>,
|
|
||||||
pub updated_at: DateTime<Utc>,
|
|
||||||
pub last_login_at: Option<DateTime<Utc>>,
|
|
||||||
/// Who created/invited this user (nullable for bootstrap users).
|
|
||||||
pub created_by: Option<String>,
|
|
||||||
pub metadata: serde_json::Value,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// An API token for authenticating requests (hash stored, never plaintext).
|
|
||||||
#[derive(Debug, Clone)]
|
|
||||||
pub struct ApiTokenRecord {
|
|
||||||
pub id: Uuid,
|
|
||||||
pub user_id: String,
|
|
||||||
/// Human label (e.g. "my-laptop", "ci-bot").
|
|
||||||
pub name: String,
|
|
||||||
/// First 8 hex chars of the plaintext token for display/identification.
|
|
||||||
pub token_prefix: String,
|
|
||||||
pub expires_at: Option<DateTime<Utc>>,
|
|
||||||
pub last_used_at: Option<DateTime<Utc>>,
|
|
||||||
pub created_at: DateTime<Utc>,
|
|
||||||
/// Soft-revoke timestamp. Non-null means revoked.
|
|
||||||
pub revoked_at: Option<DateTime<Utc>>,
|
|
||||||
}
|
|
||||||
|
|
||||||
// ==================== Sub-traits ====================
|
// ==================== Sub-traits ====================
|
||||||
//
|
//
|
||||||
// Each sub-trait groups related persistence methods. The `Database` supertrait
|
// Each sub-trait groups related persistence methods. The `Database` supertrait
|
||||||
@@ -798,81 +761,6 @@ pub trait WorkspaceStore: Send + Sync {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[async_trait]
|
|
||||||
pub trait UserStore: Send + Sync {
|
|
||||||
// ---- Users ----
|
|
||||||
|
|
||||||
/// Create a new user record.
|
|
||||||
async fn create_user(&self, user: &UserRecord) -> Result<(), DatabaseError>;
|
|
||||||
/// Get a user by their string id.
|
|
||||||
async fn get_user(&self, id: &str) -> Result<Option<UserRecord>, DatabaseError>;
|
|
||||||
/// Get a user by email address.
|
|
||||||
async fn get_user_by_email(&self, email: &str) -> Result<Option<UserRecord>, DatabaseError>;
|
|
||||||
/// List users, optionally filtered by status.
|
|
||||||
async fn list_users(&self, status: Option<&str>) -> Result<Vec<UserRecord>, DatabaseError>;
|
|
||||||
/// Update a user's status (active/suspended/deactivated).
|
|
||||||
async fn update_user_status(&self, id: &str, status: &str) -> Result<(), DatabaseError>;
|
|
||||||
/// Update a user's display name and metadata.
|
|
||||||
async fn update_user_profile(
|
|
||||||
&self,
|
|
||||||
id: &str,
|
|
||||||
display_name: &str,
|
|
||||||
metadata: &serde_json::Value,
|
|
||||||
) -> Result<(), DatabaseError>;
|
|
||||||
/// Record a login timestamp.
|
|
||||||
async fn record_login(&self, id: &str) -> Result<(), DatabaseError>;
|
|
||||||
|
|
||||||
// ---- API Tokens ----
|
|
||||||
|
|
||||||
/// Create a new API token. The `token_hash` is SHA-256 of the plaintext.
|
|
||||||
async fn create_api_token(
|
|
||||||
&self,
|
|
||||||
user_id: &str,
|
|
||||||
name: &str,
|
|
||||||
token_hash: &[u8; 32],
|
|
||||||
token_prefix: &str,
|
|
||||||
expires_at: Option<DateTime<Utc>>,
|
|
||||||
) -> Result<ApiTokenRecord, DatabaseError>;
|
|
||||||
/// List tokens for a user (never includes the hash).
|
|
||||||
async fn list_api_tokens(&self, user_id: &str) -> Result<Vec<ApiTokenRecord>, DatabaseError>;
|
|
||||||
/// Soft-revoke a token. Returns false if the token doesn't exist or doesn't belong to the user.
|
|
||||||
async fn revoke_api_token(&self, token_id: Uuid, user_id: &str) -> Result<bool, DatabaseError>;
|
|
||||||
/// Look up a token by hash, returning the token record and its owning user.
|
|
||||||
/// Only returns active (non-revoked, non-expired) tokens for active users.
|
|
||||||
async fn authenticate_token(
|
|
||||||
&self,
|
|
||||||
token_hash: &[u8; 32],
|
|
||||||
) -> Result<Option<(ApiTokenRecord, UserRecord)>, DatabaseError>;
|
|
||||||
/// Update `last_used_at` for a token.
|
|
||||||
async fn record_token_usage(&self, token_id: Uuid) -> Result<(), DatabaseError>;
|
|
||||||
|
|
||||||
/// Check whether any user records exist (for first-run bootstrap detection).
|
|
||||||
async fn has_any_users(&self) -> Result<bool, DatabaseError>;
|
|
||||||
|
|
||||||
/// Delete a user and all their data across all user-scoped tables.
|
|
||||||
/// Returns false if the user doesn't exist.
|
|
||||||
async fn delete_user(&self, id: &str) -> Result<bool, DatabaseError>;
|
|
||||||
|
|
||||||
/// Get per-user LLM usage stats for a time period.
|
|
||||||
/// Aggregates from llm_calls via agent_jobs.user_id.
|
|
||||||
async fn user_usage_stats(
|
|
||||||
&self,
|
|
||||||
user_id: Option<&str>,
|
|
||||||
since: DateTime<Utc>,
|
|
||||||
) -> Result<Vec<UserUsageStats>, DatabaseError>;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Per-user LLM usage statistics.
|
|
||||||
#[derive(Debug, Clone)]
|
|
||||||
pub struct UserUsageStats {
|
|
||||||
pub user_id: String,
|
|
||||||
pub model: String,
|
|
||||||
pub call_count: i64,
|
|
||||||
pub input_tokens: i64,
|
|
||||||
pub output_tokens: i64,
|
|
||||||
pub total_cost: Decimal,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Backend-agnostic database supertrait.
|
/// Backend-agnostic database supertrait.
|
||||||
///
|
///
|
||||||
/// Combines all sub-traits into one. Existing `Arc<dyn Database>` consumers
|
/// Combines all sub-traits into one. Existing `Arc<dyn Database>` consumers
|
||||||
@@ -886,7 +774,6 @@ pub trait Database:
|
|||||||
+ ToolFailureStore
|
+ ToolFailureStore
|
||||||
+ SettingsStore
|
+ SettingsStore
|
||||||
+ WorkspaceStore
|
+ WorkspaceStore
|
||||||
+ UserStore
|
|
||||||
+ Send
|
+ Send
|
||||||
+ Sync
|
+ Sync
|
||||||
{
|
{
|
||||||
|
|||||||
+2
-90
@@ -16,8 +16,8 @@ use crate::agent::routine::{Routine, RoutineRun, RunStatus};
|
|||||||
use crate::config::DatabaseConfig;
|
use crate::config::DatabaseConfig;
|
||||||
use crate::context::{ActionRecord, JobContext, JobState};
|
use crate::context::{ActionRecord, JobContext, JobState};
|
||||||
use crate::db::{
|
use crate::db::{
|
||||||
ApiTokenRecord, ConversationStore, Database, JobStore, RoutineStore, SandboxStore,
|
ConversationStore, Database, JobStore, RoutineStore, SandboxStore, SettingsStore,
|
||||||
SettingsStore, ToolFailureStore, UserRecord, UserStore, WorkspaceStore,
|
ToolFailureStore, WorkspaceStore,
|
||||||
};
|
};
|
||||||
use crate::error::{DatabaseError, WorkspaceError};
|
use crate::error::{DatabaseError, WorkspaceError};
|
||||||
use crate::history::{
|
use crate::history::{
|
||||||
@@ -786,91 +786,3 @@ impl WorkspaceStore for PgBackend {
|
|||||||
.await
|
.await
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ==================== UserStore ====================
|
|
||||||
|
|
||||||
#[async_trait]
|
|
||||||
impl UserStore for PgBackend {
|
|
||||||
async fn create_user(&self, user: &UserRecord) -> Result<(), DatabaseError> {
|
|
||||||
self.store.create_user(user).await
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn get_user(&self, id: &str) -> Result<Option<UserRecord>, DatabaseError> {
|
|
||||||
self.store.get_user(id).await
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn get_user_by_email(&self, email: &str) -> Result<Option<UserRecord>, DatabaseError> {
|
|
||||||
self.store.get_user_by_email(email).await
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn list_users(&self, status: Option<&str>) -> Result<Vec<UserRecord>, DatabaseError> {
|
|
||||||
self.store.list_users(status).await
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn update_user_status(&self, id: &str, status: &str) -> Result<(), DatabaseError> {
|
|
||||||
self.store.update_user_status(id, status).await
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn update_user_profile(
|
|
||||||
&self,
|
|
||||||
id: &str,
|
|
||||||
display_name: &str,
|
|
||||||
metadata: &serde_json::Value,
|
|
||||||
) -> Result<(), DatabaseError> {
|
|
||||||
self.store
|
|
||||||
.update_user_profile(id, display_name, metadata)
|
|
||||||
.await
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn record_login(&self, id: &str) -> Result<(), DatabaseError> {
|
|
||||||
self.store.record_login(id).await
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn create_api_token(
|
|
||||||
&self,
|
|
||||||
user_id: &str,
|
|
||||||
name: &str,
|
|
||||||
token_hash: &[u8; 32],
|
|
||||||
token_prefix: &str,
|
|
||||||
expires_at: Option<DateTime<Utc>>,
|
|
||||||
) -> Result<ApiTokenRecord, DatabaseError> {
|
|
||||||
self.store
|
|
||||||
.create_api_token(user_id, name, token_hash, token_prefix, expires_at)
|
|
||||||
.await
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn list_api_tokens(&self, user_id: &str) -> Result<Vec<ApiTokenRecord>, DatabaseError> {
|
|
||||||
self.store.list_api_tokens(user_id).await
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn revoke_api_token(&self, token_id: Uuid, user_id: &str) -> Result<bool, DatabaseError> {
|
|
||||||
self.store.revoke_api_token(token_id, user_id).await
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn authenticate_token(
|
|
||||||
&self,
|
|
||||||
token_hash: &[u8; 32],
|
|
||||||
) -> Result<Option<(ApiTokenRecord, UserRecord)>, DatabaseError> {
|
|
||||||
self.store.authenticate_token(token_hash).await
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn record_token_usage(&self, token_id: Uuid) -> Result<(), DatabaseError> {
|
|
||||||
self.store.record_token_usage(token_id).await
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn has_any_users(&self) -> Result<bool, DatabaseError> {
|
|
||||||
self.store.has_any_users().await
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn delete_user(&self, id: &str) -> Result<bool, DatabaseError> {
|
|
||||||
self.store.delete_user(id).await
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn user_usage_stats(
|
|
||||||
&self,
|
|
||||||
user_id: Option<&str>,
|
|
||||||
since: DateTime<Utc>,
|
|
||||||
) -> Result<Vec<crate::db::UserUsageStats>, DatabaseError> {
|
|
||||||
self.store.user_usage_stats(user_id, since).await
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
+7
-18
@@ -2,8 +2,7 @@
|
|||||||
//!
|
//!
|
||||||
//! Builds a [`deadpool_postgres::Pool`] with the appropriate TLS connector
|
//! Builds a [`deadpool_postgres::Pool`] with the appropriate TLS connector
|
||||||
//! based on the configured [`SslMode`]. Uses `rustls` with system root
|
//! based on the configured [`SslMode`]. Uses `rustls` with system root
|
||||||
//! certificates, falling back to Mozilla's bundled roots via `webpki-roots`
|
//! certificates — the same TLS stack that `reqwest` already uses for HTTP.
|
||||||
//! when the system store is empty (common in minimal container images).
|
|
||||||
|
|
||||||
use deadpool_postgres::{Pool, Runtime};
|
use deadpool_postgres::{Pool, Runtime};
|
||||||
use thiserror::Error;
|
use thiserror::Error;
|
||||||
@@ -20,15 +19,9 @@ pub enum CreatePoolError {
|
|||||||
TlsConfig(#[from] rustls::Error),
|
TlsConfig(#[from] rustls::Error),
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Build a rustls-based TLS connector.
|
/// Build a rustls-based TLS connector using the platform's root certificate store.
|
||||||
///
|
|
||||||
/// Tries the platform's native certificate store first. If that yields zero
|
|
||||||
/// certificates (slim container images, missing ca-certificates package),
|
|
||||||
/// falls back to Mozilla's root certificates bundled via `webpki-roots`.
|
|
||||||
fn make_rustls_connector() -> Result<MakeRustlsConnect, rustls::Error> {
|
fn make_rustls_connector() -> Result<MakeRustlsConnect, rustls::Error> {
|
||||||
let mut root_store = rustls::RootCertStore::empty();
|
let mut root_store = rustls::RootCertStore::empty();
|
||||||
|
|
||||||
// Try native certs first.
|
|
||||||
let native = rustls_native_certs::load_native_certs();
|
let native = rustls_native_certs::load_native_certs();
|
||||||
for e in &native.errors {
|
for e in &native.errors {
|
||||||
tracing::warn!("error loading system root certs: {e}");
|
tracing::warn!("error loading system root certs: {e}");
|
||||||
@@ -38,16 +31,11 @@ fn make_rustls_connector() -> Result<MakeRustlsConnect, rustls::Error> {
|
|||||||
tracing::warn!("skipping invalid system root cert: {e}");
|
tracing::warn!("skipping invalid system root cert: {e}");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fall back to bundled Mozilla roots when the system store is empty.
|
|
||||||
if root_store.is_empty() {
|
if root_store.is_empty() {
|
||||||
tracing::info!(
|
tracing::error!("no system root certificates found -- TLS connections will fail");
|
||||||
"no system root certificates found, using bundled Mozilla roots"
|
|
||||||
);
|
|
||||||
root_store.extend(webpki_roots::TLS_SERVER_ROOTS.iter().cloned());
|
|
||||||
}
|
}
|
||||||
|
// `--all-features` brings in both aws-lc-rs and ring-backed rustls providers.
|
||||||
// Pick the ring crypto provider (same one reqwest uses).
|
// Pick the same ring provider reqwest already uses so postgres TLS setup stays deterministic.
|
||||||
let config = rustls::ClientConfig::builder_with_provider(
|
let config = rustls::ClientConfig::builder_with_provider(
|
||||||
rustls::crypto::ring::default_provider().into(),
|
rustls::crypto::ring::default_provider().into(),
|
||||||
)
|
)
|
||||||
@@ -60,7 +48,7 @@ fn make_rustls_connector() -> Result<MakeRustlsConnect, rustls::Error> {
|
|||||||
/// Create a [`deadpool_postgres::Pool`] with the appropriate TLS connector.
|
/// Create a [`deadpool_postgres::Pool`] with the appropriate TLS connector.
|
||||||
///
|
///
|
||||||
/// - `Disable` → plain TCP (no TLS)
|
/// - `Disable` → plain TCP (no TLS)
|
||||||
/// - `Prefer` / `Require` → rustls with system or bundled root certificates
|
/// - `Prefer` / `Require` → rustls with system root certificates
|
||||||
///
|
///
|
||||||
/// **Note:** `Prefer` and `Require` currently behave identically — both
|
/// **Note:** `Prefer` and `Require` currently behave identically — both
|
||||||
/// provide a TLS connector and will fail if the server rejects the TLS
|
/// provide a TLS connector and will fail if the server rejects the TLS
|
||||||
@@ -93,6 +81,7 @@ mod tests {
|
|||||||
fn create_pool_disable_mode() {
|
fn create_pool_disable_mode() {
|
||||||
let mut config = deadpool_postgres::Config::new();
|
let mut config = deadpool_postgres::Config::new();
|
||||||
config.url = Some("postgres://localhost/test".to_string());
|
config.url = Some("postgres://localhost/test".to_string());
|
||||||
|
// Should succeed — pool is created lazily, no actual connection needed.
|
||||||
let pool = create_pool(&config, SslMode::Disable);
|
let pool = create_pool(&config, SslMode::Disable);
|
||||||
assert!(pool.is_ok());
|
assert!(pool.is_ok());
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2279,410 +2279,6 @@ impl Store {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ==================== Users / API Tokens / Invitations ====================
|
|
||||||
|
|
||||||
#[cfg(feature = "postgres")]
|
|
||||||
use crate::db::{ApiTokenRecord, UserRecord};
|
|
||||||
|
|
||||||
#[cfg(feature = "postgres")]
|
|
||||||
impl Store {
|
|
||||||
/// Create a new user record.
|
|
||||||
pub async fn create_user(&self, user: &UserRecord) -> Result<(), DatabaseError> {
|
|
||||||
let conn = self.conn().await?;
|
|
||||||
conn.execute(
|
|
||||||
r#"
|
|
||||||
INSERT INTO users (id, email, display_name, status, role, created_at, updated_at, last_login_at, created_by, metadata)
|
|
||||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)
|
|
||||||
"#,
|
|
||||||
&[
|
|
||||||
&user.id,
|
|
||||||
&user.email,
|
|
||||||
&user.display_name,
|
|
||||||
&user.status,
|
|
||||||
&user.role,
|
|
||||||
&user.created_at,
|
|
||||||
&user.updated_at,
|
|
||||||
&user.last_login_at,
|
|
||||||
&user.created_by,
|
|
||||||
&user.metadata,
|
|
||||||
],
|
|
||||||
)
|
|
||||||
.await?;
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Get a user by their string id.
|
|
||||||
pub async fn get_user(&self, id: &str) -> Result<Option<UserRecord>, DatabaseError> {
|
|
||||||
let conn = self.conn().await?;
|
|
||||||
let row = conn
|
|
||||||
.query_opt("SELECT id, email, display_name, status, role, created_at, updated_at, last_login_at, created_by, metadata FROM users WHERE id = $1", &[&id])
|
|
||||||
.await?;
|
|
||||||
Ok(row.map(|r| row_to_user(&r)))
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Get a user by email address.
|
|
||||||
pub async fn get_user_by_email(
|
|
||||||
&self,
|
|
||||||
email: &str,
|
|
||||||
) -> Result<Option<UserRecord>, DatabaseError> {
|
|
||||||
let conn = self.conn().await?;
|
|
||||||
let row = conn
|
|
||||||
.query_opt("SELECT id, email, display_name, status, role, created_at, updated_at, last_login_at, created_by, metadata FROM users WHERE email = $1", &[&email])
|
|
||||||
.await?;
|
|
||||||
Ok(row.map(|r| row_to_user(&r)))
|
|
||||||
}
|
|
||||||
|
|
||||||
/// List users, optionally filtered by status.
|
|
||||||
pub async fn list_users(&self, status: Option<&str>) -> Result<Vec<UserRecord>, DatabaseError> {
|
|
||||||
let conn = self.conn().await?;
|
|
||||||
let rows = match status {
|
|
||||||
Some(s) => {
|
|
||||||
conn.query(
|
|
||||||
"SELECT id, email, display_name, status, role, created_at, updated_at, last_login_at, created_by, metadata FROM users WHERE status = $1 ORDER BY created_at DESC",
|
|
||||||
&[&s],
|
|
||||||
)
|
|
||||||
.await?
|
|
||||||
}
|
|
||||||
None => {
|
|
||||||
conn.query("SELECT id, email, display_name, status, role, created_at, updated_at, last_login_at, created_by, metadata FROM users ORDER BY created_at DESC", &[])
|
|
||||||
.await?
|
|
||||||
}
|
|
||||||
};
|
|
||||||
Ok(rows.iter().map(row_to_user).collect())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Update a user's status.
|
|
||||||
pub async fn update_user_status(&self, id: &str, status: &str) -> Result<(), DatabaseError> {
|
|
||||||
let conn = self.conn().await?;
|
|
||||||
conn.execute(
|
|
||||||
"UPDATE users SET status = $1, updated_at = NOW() WHERE id = $2",
|
|
||||||
&[&status, &id],
|
|
||||||
)
|
|
||||||
.await?;
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Update a user's display name and metadata.
|
|
||||||
pub async fn update_user_profile(
|
|
||||||
&self,
|
|
||||||
id: &str,
|
|
||||||
display_name: &str,
|
|
||||||
metadata: &serde_json::Value,
|
|
||||||
) -> Result<(), DatabaseError> {
|
|
||||||
let conn = self.conn().await?;
|
|
||||||
conn.execute(
|
|
||||||
"UPDATE users SET display_name = $1, metadata = $2, updated_at = NOW() WHERE id = $3",
|
|
||||||
&[&display_name, metadata, &id],
|
|
||||||
)
|
|
||||||
.await?;
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Record a login timestamp for a user.
|
|
||||||
pub async fn record_login(&self, id: &str) -> Result<(), DatabaseError> {
|
|
||||||
let conn = self.conn().await?;
|
|
||||||
conn.execute(
|
|
||||||
"UPDATE users SET last_login_at = NOW(), updated_at = NOW() WHERE id = $1",
|
|
||||||
&[&id],
|
|
||||||
)
|
|
||||||
.await?;
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Create a new API token.
|
|
||||||
pub async fn create_api_token(
|
|
||||||
&self,
|
|
||||||
user_id: &str,
|
|
||||||
name: &str,
|
|
||||||
token_hash: &[u8; 32],
|
|
||||||
token_prefix: &str,
|
|
||||||
expires_at: Option<DateTime<Utc>>,
|
|
||||||
) -> Result<ApiTokenRecord, DatabaseError> {
|
|
||||||
let conn = self.conn().await?;
|
|
||||||
let id = Uuid::new_v4();
|
|
||||||
let now = Utc::now();
|
|
||||||
conn.execute(
|
|
||||||
r#"
|
|
||||||
INSERT INTO api_tokens (id, user_id, token_hash, token_prefix, name, expires_at, created_at)
|
|
||||||
VALUES ($1, $2, $3, $4, $5, $6, $7)
|
|
||||||
"#,
|
|
||||||
&[
|
|
||||||
&id,
|
|
||||||
&user_id,
|
|
||||||
&token_hash.to_vec(),
|
|
||||||
&token_prefix,
|
|
||||||
&name,
|
|
||||||
&expires_at,
|
|
||||||
&now,
|
|
||||||
],
|
|
||||||
)
|
|
||||||
.await?;
|
|
||||||
Ok(ApiTokenRecord {
|
|
||||||
id,
|
|
||||||
user_id: user_id.to_string(),
|
|
||||||
name: name.to_string(),
|
|
||||||
token_prefix: token_prefix.to_string(),
|
|
||||||
expires_at,
|
|
||||||
last_used_at: None,
|
|
||||||
created_at: now,
|
|
||||||
revoked_at: None,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
/// List tokens for a user.
|
|
||||||
pub async fn list_api_tokens(
|
|
||||||
&self,
|
|
||||||
user_id: &str,
|
|
||||||
) -> Result<Vec<ApiTokenRecord>, DatabaseError> {
|
|
||||||
let conn = self.conn().await?;
|
|
||||||
let rows = conn
|
|
||||||
.query(
|
|
||||||
r#"
|
|
||||||
SELECT id, user_id, name, token_prefix, expires_at, last_used_at, created_at, revoked_at
|
|
||||||
FROM api_tokens
|
|
||||||
WHERE user_id = $1
|
|
||||||
ORDER BY created_at DESC
|
|
||||||
"#,
|
|
||||||
&[&user_id],
|
|
||||||
)
|
|
||||||
.await?;
|
|
||||||
Ok(rows.iter().map(row_to_api_token).collect())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Soft-revoke a token. Returns false if the token doesn't exist or doesn't belong to the user.
|
|
||||||
pub async fn revoke_api_token(
|
|
||||||
&self,
|
|
||||||
token_id: Uuid,
|
|
||||||
user_id: &str,
|
|
||||||
) -> Result<bool, DatabaseError> {
|
|
||||||
let conn = self.conn().await?;
|
|
||||||
let count = conn
|
|
||||||
.execute(
|
|
||||||
"UPDATE api_tokens SET revoked_at = NOW() WHERE id = $1 AND user_id = $2 AND revoked_at IS NULL",
|
|
||||||
&[&token_id, &user_id],
|
|
||||||
)
|
|
||||||
.await?;
|
|
||||||
Ok(count > 0)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Authenticate a token by hash. Returns the token record and its owning user
|
|
||||||
/// if the token is active (non-revoked, non-expired) and the user is active.
|
|
||||||
pub async fn authenticate_token(
|
|
||||||
&self,
|
|
||||||
token_hash: &[u8; 32],
|
|
||||||
) -> Result<Option<(ApiTokenRecord, UserRecord)>, DatabaseError> {
|
|
||||||
let conn = self.conn().await?;
|
|
||||||
let row = conn
|
|
||||||
.query_opt(
|
|
||||||
r#"
|
|
||||||
SELECT t.id, t.user_id, t.name, t.token_prefix, t.expires_at, t.last_used_at, t.created_at, t.revoked_at,
|
|
||||||
u.id as u_id, u.email, u.display_name, u.status, u.role, u.created_at as u_created_at, u.updated_at, u.last_login_at, u.created_by, u.metadata
|
|
||||||
FROM api_tokens t
|
|
||||||
JOIN users u ON t.user_id = u.id
|
|
||||||
WHERE t.token_hash = $1
|
|
||||||
AND t.revoked_at IS NULL
|
|
||||||
AND (t.expires_at IS NULL OR t.expires_at > NOW())
|
|
||||||
AND u.status = 'active'
|
|
||||||
"#,
|
|
||||||
&[&token_hash.to_vec()],
|
|
||||||
)
|
|
||||||
.await?;
|
|
||||||
Ok(row.map(|r| {
|
|
||||||
let token = ApiTokenRecord {
|
|
||||||
id: r.get("id"),
|
|
||||||
user_id: r.get("user_id"),
|
|
||||||
name: r.get("name"),
|
|
||||||
token_prefix: r.get("token_prefix"),
|
|
||||||
expires_at: r.get("expires_at"),
|
|
||||||
last_used_at: r.get("last_used_at"),
|
|
||||||
created_at: r.get("created_at"),
|
|
||||||
revoked_at: r.get("revoked_at"),
|
|
||||||
};
|
|
||||||
let user = UserRecord {
|
|
||||||
id: r.get("u_id"),
|
|
||||||
email: r.get("email"),
|
|
||||||
display_name: r.get("display_name"),
|
|
||||||
status: r.get("status"),
|
|
||||||
role: r.get("role"),
|
|
||||||
created_at: r.get("u_created_at"),
|
|
||||||
updated_at: r.get("updated_at"),
|
|
||||||
last_login_at: r.get("last_login_at"),
|
|
||||||
created_by: r.get("created_by"),
|
|
||||||
metadata: r.get("metadata"),
|
|
||||||
};
|
|
||||||
(token, user)
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Update `last_used_at` for a token.
|
|
||||||
pub async fn record_token_usage(&self, token_id: Uuid) -> Result<(), DatabaseError> {
|
|
||||||
let conn = self.conn().await?;
|
|
||||||
conn.execute(
|
|
||||||
"UPDATE api_tokens SET last_used_at = NOW() WHERE id = $1",
|
|
||||||
&[&token_id],
|
|
||||||
)
|
|
||||||
.await?;
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Check whether any user records exist.
|
|
||||||
pub async fn has_any_users(&self) -> Result<bool, DatabaseError> {
|
|
||||||
let conn = self.conn().await?;
|
|
||||||
let row = conn
|
|
||||||
.query_one(
|
|
||||||
"SELECT EXISTS(SELECT 1 FROM users LIMIT 1) as has_users",
|
|
||||||
&[],
|
|
||||||
)
|
|
||||||
.await?;
|
|
||||||
Ok(row.get("has_users"))
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Delete a user and all their data across all user-scoped tables.
|
|
||||||
/// Returns false if the user doesn't exist.
|
|
||||||
pub async fn delete_user(&self, id: &str) -> Result<bool, DatabaseError> {
|
|
||||||
let mut conn = self.conn().await?;
|
|
||||||
let tx = conn
|
|
||||||
.transaction()
|
|
||||||
.await
|
|
||||||
.map_err(|e| DatabaseError::Query(e.to_string()))?;
|
|
||||||
// Delete from child tables first to avoid FK violations.
|
|
||||||
// job_events must come before agent_jobs (FK without CASCADE).
|
|
||||||
// agent_jobs cascades to job_actions, llm_calls, estimation_snapshots.
|
|
||||||
// conversations cascades to conversation_messages.
|
|
||||||
// memory_documents cascades to memory_chunks.
|
|
||||||
// routines cascades to routine_runs.
|
|
||||||
// api_tokens cascade automatically via FK on users.
|
|
||||||
for table in &[
|
|
||||||
"settings",
|
|
||||||
"heartbeat_state",
|
|
||||||
"tool_rate_limit_state",
|
|
||||||
"secret_usage_log",
|
|
||||||
"leak_detection_events",
|
|
||||||
"secrets",
|
|
||||||
"wasm_tools",
|
|
||||||
"routines",
|
|
||||||
"memory_documents",
|
|
||||||
"conversations",
|
|
||||||
] {
|
|
||||||
tx.execute(&format!("DELETE FROM {table} WHERE user_id = $1"), &[&id])
|
|
||||||
.await
|
|
||||||
.map_err(|e| DatabaseError::Query(e.to_string()))?;
|
|
||||||
}
|
|
||||||
// job_events references agent_jobs(id) without CASCADE — delete via subquery.
|
|
||||||
tx.execute(
|
|
||||||
"DELETE FROM job_events WHERE job_id IN (SELECT id FROM agent_jobs WHERE user_id = $1)",
|
|
||||||
&[&id],
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
.map_err(|e| DatabaseError::Query(e.to_string()))?;
|
|
||||||
tx.execute("DELETE FROM agent_jobs WHERE user_id = $1", &[&id])
|
|
||||||
.await
|
|
||||||
.map_err(|e| DatabaseError::Query(e.to_string()))?;
|
|
||||||
// Nullify self-referencing created_by before deleting the user
|
|
||||||
tx.execute(
|
|
||||||
"UPDATE users SET created_by = NULL WHERE created_by = $1",
|
|
||||||
&[&id],
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
.map_err(|e| DatabaseError::Query(e.to_string()))?;
|
|
||||||
// api_tokens cascade automatically via FK
|
|
||||||
let result = tx
|
|
||||||
.execute("DELETE FROM users WHERE id = $1", &[&id])
|
|
||||||
.await
|
|
||||||
.map_err(|e| DatabaseError::Query(e.to_string()))?;
|
|
||||||
tx.commit()
|
|
||||||
.await
|
|
||||||
.map_err(|e| DatabaseError::Query(e.to_string()))?;
|
|
||||||
Ok(result > 0)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Get per-user LLM usage stats for a time period.
|
|
||||||
/// Aggregates from llm_calls via agent_jobs.user_id.
|
|
||||||
pub async fn user_usage_stats(
|
|
||||||
&self,
|
|
||||||
user_id: Option<&str>,
|
|
||||||
since: DateTime<Utc>,
|
|
||||||
) -> Result<Vec<crate::db::UserUsageStats>, DatabaseError> {
|
|
||||||
let conn = self.conn().await?;
|
|
||||||
let rows = if let Some(uid) = user_id {
|
|
||||||
conn.query(
|
|
||||||
r#"
|
|
||||||
SELECT j.user_id, l.model, COUNT(*) as call_count,
|
|
||||||
COALESCE(SUM(l.input_tokens), 0) as input_tokens,
|
|
||||||
COALESCE(SUM(l.output_tokens), 0) as output_tokens,
|
|
||||||
COALESCE(SUM(l.cost), 0) as total_cost
|
|
||||||
FROM llm_calls l
|
|
||||||
JOIN agent_jobs j ON l.job_id = j.id
|
|
||||||
WHERE l.created_at >= $1
|
|
||||||
AND j.user_id = $2
|
|
||||||
GROUP BY j.user_id, l.model
|
|
||||||
ORDER BY total_cost DESC
|
|
||||||
"#,
|
|
||||||
&[&since, &uid],
|
|
||||||
)
|
|
||||||
.await?
|
|
||||||
} else {
|
|
||||||
conn.query(
|
|
||||||
r#"
|
|
||||||
SELECT j.user_id, l.model, COUNT(*) as call_count,
|
|
||||||
COALESCE(SUM(l.input_tokens), 0) as input_tokens,
|
|
||||||
COALESCE(SUM(l.output_tokens), 0) as output_tokens,
|
|
||||||
COALESCE(SUM(l.cost), 0) as total_cost
|
|
||||||
FROM llm_calls l
|
|
||||||
JOIN agent_jobs j ON l.job_id = j.id
|
|
||||||
WHERE l.created_at >= $1
|
|
||||||
GROUP BY j.user_id, l.model
|
|
||||||
ORDER BY total_cost DESC
|
|
||||||
"#,
|
|
||||||
&[&since],
|
|
||||||
)
|
|
||||||
.await?
|
|
||||||
};
|
|
||||||
let mut stats = Vec::with_capacity(rows.len());
|
|
||||||
for row in &rows {
|
|
||||||
stats.push(crate::db::UserUsageStats {
|
|
||||||
user_id: row.get("user_id"),
|
|
||||||
model: row.get("model"),
|
|
||||||
call_count: row.get("call_count"),
|
|
||||||
input_tokens: row.get("input_tokens"),
|
|
||||||
output_tokens: row.get("output_tokens"),
|
|
||||||
total_cost: row.get("total_cost"),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
Ok(stats)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(feature = "postgres")]
|
|
||||||
fn row_to_user(row: &tokio_postgres::Row) -> UserRecord {
|
|
||||||
UserRecord {
|
|
||||||
id: row.get("id"),
|
|
||||||
email: row.get("email"),
|
|
||||||
display_name: row.get("display_name"),
|
|
||||||
status: row.get("status"),
|
|
||||||
role: row.get("role"),
|
|
||||||
created_at: row.get("created_at"),
|
|
||||||
updated_at: row.get("updated_at"),
|
|
||||||
last_login_at: row.get("last_login_at"),
|
|
||||||
created_by: row.get("created_by"),
|
|
||||||
metadata: row.get("metadata"),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(feature = "postgres")]
|
|
||||||
fn row_to_api_token(row: &tokio_postgres::Row) -> ApiTokenRecord {
|
|
||||||
ApiTokenRecord {
|
|
||||||
id: row.get("id"),
|
|
||||||
user_id: row.get("user_id"),
|
|
||||||
name: row.get("name"),
|
|
||||||
token_prefix: row.get("token_prefix"),
|
|
||||||
expires_at: row.get("expires_at"),
|
|
||||||
last_used_at: row.get("last_used_at"),
|
|
||||||
created_at: row.get("created_at"),
|
|
||||||
revoked_at: row.get("revoked_at"),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|||||||
+5
-55
@@ -601,14 +601,11 @@ fn build_rig_request(
|
|||||||
/// Inject a per-request model override into the rig request's `additional_params`.
|
/// Inject a per-request model override into the rig request's `additional_params`.
|
||||||
///
|
///
|
||||||
/// Rig-core bakes the model name at construction time inside each provider's
|
/// Rig-core bakes the model name at construction time inside each provider's
|
||||||
/// `CompletionModel` implementation. This helper inserts a top-level `"model"`
|
/// `CompletionModel` implementation. The actual HTTP request body includes a
|
||||||
/// key into `additional_params`, which rig-core flattens into the provider's
|
/// `model` field set by the provider. Rig-core's `#[serde(flatten)]` on
|
||||||
/// request payload via `#[serde(flatten)]`.
|
/// `additional_params` emits these fields AFTER the provider's own fields.
|
||||||
///
|
/// Most API servers (Python, Go) use last-key-wins when deserializing
|
||||||
/// Whether the override takes effect depends on the downstream API server's
|
/// duplicate JSON keys, so the injected `model` value takes effect.
|
||||||
/// handling of duplicate JSON keys (most Python/Go servers use last-key-wins,
|
|
||||||
/// but this is not guaranteed by the JSON spec). The `effective_model_name()`
|
|
||||||
/// trait method should be consulted to determine the model actually used.
|
|
||||||
fn inject_model_override(rig_req: &mut RigRequest, model_override: Option<&str>) {
|
fn inject_model_override(rig_req: &mut RigRequest, model_override: Option<&str>) {
|
||||||
let Some(model) = model_override else {
|
let Some(model) = model_override else {
|
||||||
return;
|
return;
|
||||||
@@ -1518,51 +1515,4 @@ mod tests {
|
|||||||
"different raw IDs should produce different hashed IDs"
|
"different raw IDs should produce different hashed IDs"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
fn make_rig_request(additional_params: Option<serde_json::Value>) -> RigRequest {
|
|
||||||
RigRequest {
|
|
||||||
preamble: None,
|
|
||||||
chat_history: OneOrMany::one(RigMessage::user("test")),
|
|
||||||
documents: Vec::new(),
|
|
||||||
tools: Vec::new(),
|
|
||||||
temperature: None,
|
|
||||||
max_tokens: None,
|
|
||||||
tool_choice: None,
|
|
||||||
additional_params,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_inject_model_override_creates_params_when_none() {
|
|
||||||
let mut req = make_rig_request(None);
|
|
||||||
inject_model_override(&mut req, Some("test-model"));
|
|
||||||
|
|
||||||
let params = req
|
|
||||||
.additional_params
|
|
||||||
.expect("additional_params should be Some");
|
|
||||||
assert_eq!(params, serde_json::json!({ "model": "test-model" }));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_inject_model_override_preserves_existing_params() {
|
|
||||||
let mut req = make_rig_request(Some(serde_json::json!({
|
|
||||||
"cache_control": { "type": "ephemeral" },
|
|
||||||
})));
|
|
||||||
inject_model_override(&mut req, Some("override-model"));
|
|
||||||
|
|
||||||
let params = req.additional_params.expect("should remain Some");
|
|
||||||
let obj = params.as_object().expect("should be object");
|
|
||||||
assert_eq!(
|
|
||||||
obj.get("cache_control"),
|
|
||||||
Some(&serde_json::json!({ "type": "ephemeral" }))
|
|
||||||
);
|
|
||||||
assert_eq!(obj.get("model"), Some(&serde_json::json!("override-model")));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_inject_model_override_noop_when_none() {
|
|
||||||
let mut req = make_rig_request(None);
|
|
||||||
inject_model_override(&mut req, None);
|
|
||||||
assert!(req.additional_params.is_none());
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
+27
-50
@@ -591,7 +591,27 @@ async fn async_main() -> anyhow::Result<()> {
|
|||||||
let mut gateway_url: Option<String> = None;
|
let mut gateway_url: Option<String> = None;
|
||||||
let mut sse_manager: Option<std::sync::Arc<ironclaw::channels::web::sse::SseManager>> = None;
|
let mut sse_manager: Option<std::sync::Arc<ironclaw::channels::web::sse::SseManager>> = None;
|
||||||
if let Some(ref gw_config) = config.channels.gateway {
|
if let Some(ref gw_config) = config.channels.gateway {
|
||||||
let mut gw = GatewayChannel::new(gw_config.clone(), config.owner_id.clone());
|
// Build multi-user auth state if user_tokens is configured, else single-user.
|
||||||
|
let mut gw = if let Some(ref user_tokens) = gw_config.user_tokens {
|
||||||
|
use ironclaw::channels::web::auth::{MultiAuthState, UserIdentity};
|
||||||
|
let tokens = user_tokens
|
||||||
|
.iter()
|
||||||
|
.map(|(token, cfg)| {
|
||||||
|
(
|
||||||
|
token.clone(),
|
||||||
|
UserIdentity {
|
||||||
|
user_id: cfg.user_id.clone(),
|
||||||
|
workspace_read_scopes: cfg.workspace_read_scopes.clone(),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
let auth = MultiAuthState::multi(tokens);
|
||||||
|
GatewayChannel::new_multi_auth(gw_config.clone(), auth)
|
||||||
|
} else {
|
||||||
|
GatewayChannel::new(gw_config.clone())
|
||||||
|
};
|
||||||
|
gw = gw.with_owner_scope(config.owner_id.clone());
|
||||||
gw = gw.with_llm_provider(Arc::clone(&components.llm));
|
gw = gw.with_llm_provider(Arc::clone(&components.llm));
|
||||||
if let Some(ref ws) = components.workspace {
|
if let Some(ref ws) = components.workspace {
|
||||||
gw = gw.with_workspace(Arc::clone(ws));
|
gw = gw.with_workspace(Arc::clone(ws));
|
||||||
@@ -630,54 +650,6 @@ async fn async_main() -> anyhow::Result<()> {
|
|||||||
}
|
}
|
||||||
if let Some(ref d) = components.db {
|
if let Some(ref d) = components.db {
|
||||||
gw = gw.with_store(Arc::clone(d));
|
gw = gw.with_store(Arc::clone(d));
|
||||||
gw = gw.with_db_auth(Arc::clone(d));
|
|
||||||
if let Some(ref ss) = components.secrets_store {
|
|
||||||
gw = gw.with_secrets_store(Arc::clone(ss));
|
|
||||||
}
|
|
||||||
|
|
||||||
// Bootstrap: create the first admin user from single-user config
|
|
||||||
// so the owner appears in the Users admin panel immediately.
|
|
||||||
if let Ok(false) = d.has_any_users().await {
|
|
||||||
let now = chrono::Utc::now();
|
|
||||||
let user = ironclaw::db::UserRecord {
|
|
||||||
id: config.owner_id.clone(),
|
|
||||||
email: None,
|
|
||||||
display_name: config.owner_id.clone(),
|
|
||||||
status: "active".to_string(),
|
|
||||||
role: "admin".to_string(),
|
|
||||||
created_at: now,
|
|
||||||
updated_at: now,
|
|
||||||
last_login_at: None,
|
|
||||||
created_by: None,
|
|
||||||
metadata: serde_json::json!({"source": "bootstrap"}),
|
|
||||||
};
|
|
||||||
if let Err(e) = d.create_user(&user).await {
|
|
||||||
tracing::warn!("Failed to bootstrap admin user: {}", e);
|
|
||||||
} else {
|
|
||||||
// Also create an API token from the gateway auth token so
|
|
||||||
// DB-backed auth works for the bootstrapped user.
|
|
||||||
let auth_token = gw.auth_token();
|
|
||||||
if !auth_token.is_empty() {
|
|
||||||
use ironclaw::channels::web::auth::hash_token;
|
|
||||||
let hash = hash_token(auth_token);
|
|
||||||
let prefix = if auth_token.len() >= 8 {
|
|
||||||
&auth_token[..8]
|
|
||||||
} else {
|
|
||||||
auth_token
|
|
||||||
};
|
|
||||||
if let Err(e) = d
|
|
||||||
.create_api_token(&config.owner_id, "bootstrap", &hash, prefix, None)
|
|
||||||
.await
|
|
||||||
{
|
|
||||||
tracing::warn!("Failed to create bootstrap token: {}", e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
tracing::info!(
|
|
||||||
user_id = config.owner_id,
|
|
||||||
"Bootstrapped admin user from gateway config"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
if let Some(ref jm) = container_job_manager {
|
if let Some(ref jm) = container_job_manager {
|
||||||
gw = gw.with_job_manager(Arc::clone(jm));
|
gw = gw.with_job_manager(Arc::clone(jm));
|
||||||
@@ -819,7 +791,12 @@ async fn async_main() -> anyhow::Result<()> {
|
|||||||
.await;
|
.await;
|
||||||
|
|
||||||
// Default user ID for extension operations (single-user mode).
|
// Default user ID for extension operations (single-user mode).
|
||||||
let ext_user_id = config.owner_id.clone();
|
let ext_user_id = config
|
||||||
|
.channels
|
||||||
|
.gateway
|
||||||
|
.as_ref()
|
||||||
|
.map(|g| g.user_id.clone())
|
||||||
|
.unwrap_or_else(|| "default".to_string());
|
||||||
|
|
||||||
// Wire up channel runtime for hot-activation of WASM channels.
|
// Wire up channel runtime for hot-activation of WASM channels.
|
||||||
if let Some(ref ext_mgr) = components.extension_manager
|
if let Some(ref ext_mgr) = components.extension_manager
|
||||||
|
|||||||
@@ -269,6 +269,10 @@ pub struct ChannelSettings {
|
|||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub gateway_auth_token: Option<String>,
|
pub gateway_auth_token: Option<String>,
|
||||||
|
|
||||||
|
/// Web gateway user ID.
|
||||||
|
#[serde(default)]
|
||||||
|
pub gateway_user_id: Option<String>,
|
||||||
|
|
||||||
/// Whether the CLI channel is enabled.
|
/// Whether the CLI channel is enabled.
|
||||||
#[serde(default = "default_true")]
|
#[serde(default = "default_true")]
|
||||||
pub cli_enabled: bool,
|
pub cli_enabled: bool,
|
||||||
@@ -338,6 +342,7 @@ impl Default for ChannelSettings {
|
|||||||
gateway_host: None,
|
gateway_host: None,
|
||||||
gateway_port: None,
|
gateway_port: None,
|
||||||
gateway_auth_token: None,
|
gateway_auth_token: None,
|
||||||
|
gateway_user_id: None,
|
||||||
cli_enabled: true,
|
cli_enabled: true,
|
||||||
signal_enabled: false,
|
signal_enabled: false,
|
||||||
signal_http_url: None,
|
signal_http_url: None,
|
||||||
|
|||||||
+1
-42
@@ -330,62 +330,35 @@ impl TenantScope {
|
|||||||
|
|
||||||
/// Add a message to a conversation owned by this tenant.
|
/// Add a message to a conversation owned by this tenant.
|
||||||
///
|
///
|
||||||
/// Returns `NotFound` if the conversation does not belong to this user.
|
/// Verifies the conversation belongs to this user before adding.
|
||||||
pub async fn add_conversation_message(
|
pub async fn add_conversation_message(
|
||||||
&self,
|
&self,
|
||||||
conversation_id: Uuid,
|
conversation_id: Uuid,
|
||||||
role: &str,
|
role: &str,
|
||||||
content: &str,
|
content: &str,
|
||||||
) -> Result<Uuid, DatabaseError> {
|
) -> Result<Uuid, DatabaseError> {
|
||||||
if !self.conversation_belongs_to_user(conversation_id).await? {
|
|
||||||
return Err(DatabaseError::NotFound {
|
|
||||||
entity: "conversation".to_string(),
|
|
||||||
id: conversation_id.to_string(),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
self.inner
|
self.inner
|
||||||
.add_conversation_message(conversation_id, role, content)
|
.add_conversation_message(conversation_id, role, content)
|
||||||
.await
|
.await
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Touch a conversation timestamp. Returns `NotFound` if not owned by this user.
|
|
||||||
pub async fn touch_conversation(&self, id: Uuid) -> Result<(), DatabaseError> {
|
pub async fn touch_conversation(&self, id: Uuid) -> Result<(), DatabaseError> {
|
||||||
if !self.conversation_belongs_to_user(id).await? {
|
|
||||||
return Err(DatabaseError::NotFound {
|
|
||||||
entity: "conversation".to_string(),
|
|
||||||
id: id.to_string(),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
self.inner.touch_conversation(id).await
|
self.inner.touch_conversation(id).await
|
||||||
}
|
}
|
||||||
|
|
||||||
/// List messages in a conversation. Returns `NotFound` if not owned by this user.
|
|
||||||
pub async fn list_conversation_messages(
|
pub async fn list_conversation_messages(
|
||||||
&self,
|
&self,
|
||||||
conversation_id: Uuid,
|
conversation_id: Uuid,
|
||||||
) -> Result<Vec<ConversationMessage>, DatabaseError> {
|
) -> Result<Vec<ConversationMessage>, DatabaseError> {
|
||||||
if !self.conversation_belongs_to_user(conversation_id).await? {
|
|
||||||
return Err(DatabaseError::NotFound {
|
|
||||||
entity: "conversation".to_string(),
|
|
||||||
id: conversation_id.to_string(),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
self.inner.list_conversation_messages(conversation_id).await
|
self.inner.list_conversation_messages(conversation_id).await
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Paginated message listing. Returns `NotFound` if not owned by this user.
|
|
||||||
pub async fn list_conversation_messages_paginated(
|
pub async fn list_conversation_messages_paginated(
|
||||||
&self,
|
&self,
|
||||||
conversation_id: Uuid,
|
conversation_id: Uuid,
|
||||||
before: Option<DateTime<Utc>>,
|
before: Option<DateTime<Utc>>,
|
||||||
limit: i64,
|
limit: i64,
|
||||||
) -> Result<(Vec<ConversationMessage>, bool), DatabaseError> {
|
) -> Result<(Vec<ConversationMessage>, bool), DatabaseError> {
|
||||||
if !self.conversation_belongs_to_user(conversation_id).await? {
|
|
||||||
return Err(DatabaseError::NotFound {
|
|
||||||
entity: "conversation".to_string(),
|
|
||||||
id: conversation_id.to_string(),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
self.inner
|
self.inner
|
||||||
.list_conversation_messages_paginated(conversation_id, before, limit)
|
.list_conversation_messages_paginated(conversation_id, before, limit)
|
||||||
.await
|
.await
|
||||||
@@ -401,35 +374,21 @@ impl TenantScope {
|
|||||||
.await
|
.await
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Update metadata on a conversation. Returns `NotFound` if not owned by this user.
|
|
||||||
pub async fn update_conversation_metadata_field(
|
pub async fn update_conversation_metadata_field(
|
||||||
&self,
|
&self,
|
||||||
id: Uuid,
|
id: Uuid,
|
||||||
key: &str,
|
key: &str,
|
||||||
value: &serde_json::Value,
|
value: &serde_json::Value,
|
||||||
) -> Result<(), DatabaseError> {
|
) -> Result<(), DatabaseError> {
|
||||||
if !self.conversation_belongs_to_user(id).await? {
|
|
||||||
return Err(DatabaseError::NotFound {
|
|
||||||
entity: "conversation".to_string(),
|
|
||||||
id: id.to_string(),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
self.inner
|
self.inner
|
||||||
.update_conversation_metadata_field(id, key, value)
|
.update_conversation_metadata_field(id, key, value)
|
||||||
.await
|
.await
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Get conversation metadata. Returns `NotFound` if not owned by this user.
|
|
||||||
pub async fn get_conversation_metadata(
|
pub async fn get_conversation_metadata(
|
||||||
&self,
|
&self,
|
||||||
id: Uuid,
|
id: Uuid,
|
||||||
) -> Result<Option<serde_json::Value>, DatabaseError> {
|
) -> Result<Option<serde_json::Value>, DatabaseError> {
|
||||||
if !self.conversation_belongs_to_user(id).await? {
|
|
||||||
return Err(DatabaseError::NotFound {
|
|
||||||
entity: "conversation".to_string(),
|
|
||||||
id: id.to_string(),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
self.inner.get_conversation_metadata(id).await
|
self.inner.get_conversation_metadata(id).await
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,563 +0,0 @@
|
|||||||
//! Mock Abound API tools for demo purposes.
|
|
||||||
//!
|
|
||||||
//! These tools simulate Abound's backend API (account info, wire transfers,
|
|
||||||
//! exchange rates, notifications, forex scoring) with realistic mock data.
|
|
||||||
//! They are feature-gated behind `--features demo` and will be replaced by
|
|
||||||
//! real WASM tools once Abound's backend is live.
|
|
||||||
|
|
||||||
use std::time::Instant;
|
|
||||||
|
|
||||||
use async_trait::async_trait;
|
|
||||||
use chrono::{Datelike, Utc};
|
|
||||||
use rand::Rng;
|
|
||||||
use serde_json::json;
|
|
||||||
|
|
||||||
use crate::context::JobContext;
|
|
||||||
use crate::tools::tool::{ApprovalRequirement, Tool, ToolError, ToolOutput, require_str};
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Tool 1: Get Account Info
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
/// Returns mock Abound account data (limits, recipients, funding sources).
|
|
||||||
pub struct AboundGetAccountInfoTool;
|
|
||||||
|
|
||||||
#[async_trait]
|
|
||||||
impl Tool for AboundGetAccountInfoTool {
|
|
||||||
fn name(&self) -> &str {
|
|
||||||
"abound_get_account_info"
|
|
||||||
}
|
|
||||||
|
|
||||||
fn description(&self) -> &str {
|
|
||||||
"Retrieve the authenticated user's Abound account information including \
|
|
||||||
transfer limits, payment reasons, recipients, and funding sources."
|
|
||||||
}
|
|
||||||
|
|
||||||
fn parameters_schema(&self) -> serde_json::Value {
|
|
||||||
json!({
|
|
||||||
"type": "object",
|
|
||||||
"properties": {},
|
|
||||||
"required": []
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn execute(
|
|
||||||
&self,
|
|
||||||
_params: serde_json::Value,
|
|
||||||
_ctx: &JobContext,
|
|
||||||
) -> Result<ToolOutput, ToolError> {
|
|
||||||
let start = Instant::now();
|
|
||||||
|
|
||||||
let data = json!({
|
|
||||||
"status": "success",
|
|
||||||
"data": {
|
|
||||||
"user_id": "acc_123456",
|
|
||||||
"user_name": "John Doe",
|
|
||||||
"limits": {
|
|
||||||
"ach_limit": {
|
|
||||||
"limit": 5000,
|
|
||||||
"formatted_limit": "$5,000"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"payment_reasons": [
|
|
||||||
{ "key": "FAMILY_MAINTENANCE", "value": "Family Maintenance" },
|
|
||||||
{ "key": "GIFT", "value": "Gift" },
|
|
||||||
{ "key": "EDUCATION_SUPPORT", "value": "Education Support" },
|
|
||||||
{ "key": "MEDICAL_SUPPORT", "value": "Medical Support" }
|
|
||||||
],
|
|
||||||
"recipients": [
|
|
||||||
{
|
|
||||||
"beneficiary_ref_id": "ben_001",
|
|
||||||
"name": "Rahul Sharma",
|
|
||||||
"mask": "****2222"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"funding_sources": [
|
|
||||||
{
|
|
||||||
"funding_source_id": "fs_001",
|
|
||||||
"bank_name": "HDFC Bank",
|
|
||||||
"mask": "****2222"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
Ok(ToolOutput::success(data, start.elapsed()))
|
|
||||||
}
|
|
||||||
|
|
||||||
fn requires_sanitization(&self) -> bool {
|
|
||||||
false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Tool 2: Get Exchange Rate
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
/// Returns mock USD/INR exchange rate with slight randomization.
|
|
||||||
pub struct AboundGetExchangeRateTool;
|
|
||||||
|
|
||||||
/// Generate a mock USD/INR rate with slight jitter around 85.42.
|
|
||||||
fn mock_exchange_rate() -> f64 {
|
|
||||||
let mut rng = rand::thread_rng();
|
|
||||||
let jitter: f64 = rng.gen_range(-0.30..=0.30);
|
|
||||||
((85.42 + jitter) * 100.0).round() / 100.0
|
|
||||||
}
|
|
||||||
|
|
||||||
#[async_trait]
|
|
||||||
impl Tool for AboundGetExchangeRateTool {
|
|
||||||
fn name(&self) -> &str {
|
|
||||||
"abound_get_exchange_rate"
|
|
||||||
}
|
|
||||||
|
|
||||||
fn description(&self) -> &str {
|
|
||||||
"Get the current USD to INR exchange rate including the effective rate \
|
|
||||||
after fees. Use this before initiating any wire transfer."
|
|
||||||
}
|
|
||||||
|
|
||||||
fn parameters_schema(&self) -> serde_json::Value {
|
|
||||||
json!({
|
|
||||||
"type": "object",
|
|
||||||
"properties": {},
|
|
||||||
"required": []
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn execute(
|
|
||||||
&self,
|
|
||||||
_params: serde_json::Value,
|
|
||||||
_ctx: &JobContext,
|
|
||||||
) -> Result<ToolOutput, ToolError> {
|
|
||||||
let start = Instant::now();
|
|
||||||
|
|
||||||
let rate = mock_exchange_rate();
|
|
||||||
let effective = ((rate - 0.32) * 100.0).round() / 100.0;
|
|
||||||
|
|
||||||
let data = json!({
|
|
||||||
"status": "success",
|
|
||||||
"data": {
|
|
||||||
"from_currency": "USD",
|
|
||||||
"to_currency": "INR",
|
|
||||||
"current_exchange_rate": {
|
|
||||||
"formatted_value": format!("{rate:.2}"),
|
|
||||||
"value": rate
|
|
||||||
},
|
|
||||||
"effective_exchange_rate": {
|
|
||||||
"formatted_value": format!("{effective:.2}"),
|
|
||||||
"value": effective
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
Ok(ToolOutput::success(data, start.elapsed()))
|
|
||||||
}
|
|
||||||
|
|
||||||
fn requires_sanitization(&self) -> bool {
|
|
||||||
false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Tool 3: Send Wire
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
/// Simulates a wire transfer. Requires approval before execution.
|
|
||||||
pub struct AboundSendWireTool;
|
|
||||||
|
|
||||||
#[async_trait]
|
|
||||||
impl Tool for AboundSendWireTool {
|
|
||||||
fn name(&self) -> &str {
|
|
||||||
"abound_send_wire"
|
|
||||||
}
|
|
||||||
|
|
||||||
fn description(&self) -> &str {
|
|
||||||
"Submit a wire transfer to send USD to an INR recipient. Requires a \
|
|
||||||
funding source, beneficiary, amount in USD, and payment reason. \
|
|
||||||
The transfer amount must not exceed the user's ACH limit."
|
|
||||||
}
|
|
||||||
|
|
||||||
fn parameters_schema(&self) -> serde_json::Value {
|
|
||||||
json!({
|
|
||||||
"type": "object",
|
|
||||||
"properties": {
|
|
||||||
"funding_source_id": {
|
|
||||||
"type": "string",
|
|
||||||
"description": "Funding source ID (e.g. 'fs_001')"
|
|
||||||
},
|
|
||||||
"beneficiary_ref_id": {
|
|
||||||
"type": "string",
|
|
||||||
"description": "Beneficiary reference ID (e.g. 'ben_001')"
|
|
||||||
},
|
|
||||||
"amount": {
|
|
||||||
"type": "number",
|
|
||||||
"description": "Amount in USD to send"
|
|
||||||
},
|
|
||||||
"payment_reason_key": {
|
|
||||||
"type": "string",
|
|
||||||
"description": "Payment reason key: FAMILY_MAINTENANCE, GIFT, EDUCATION_SUPPORT, or MEDICAL_SUPPORT"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"required": ["funding_source_id", "beneficiary_ref_id", "amount", "payment_reason_key"]
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn execute(
|
|
||||||
&self,
|
|
||||||
params: serde_json::Value,
|
|
||||||
_ctx: &JobContext,
|
|
||||||
) -> Result<ToolOutput, ToolError> {
|
|
||||||
let start = Instant::now();
|
|
||||||
|
|
||||||
let _funding_source = require_str(¶ms, "funding_source_id")?;
|
|
||||||
let _beneficiary = require_str(¶ms, "beneficiary_ref_id")?;
|
|
||||||
let _reason = require_str(¶ms, "payment_reason_key")?;
|
|
||||||
|
|
||||||
let amount = params
|
|
||||||
.get("amount")
|
|
||||||
.and_then(|v| v.as_f64())
|
|
||||||
.ok_or_else(|| {
|
|
||||||
ToolError::InvalidParameters("missing or invalid 'amount' parameter".to_string())
|
|
||||||
})?;
|
|
||||||
|
|
||||||
// Enforce mock ACH limit
|
|
||||||
if amount > 5000.0 {
|
|
||||||
let data = json!({
|
|
||||||
"status": "error",
|
|
||||||
"error": {
|
|
||||||
"code": "TRANSFER_NOT_ALLOWED",
|
|
||||||
"message": format!(
|
|
||||||
"Transfer amount ${:.2} exceeds your ACH limit of $5,000.00",
|
|
||||||
amount
|
|
||||||
)
|
|
||||||
}
|
|
||||||
});
|
|
||||||
return Ok(ToolOutput::success(data, start.elapsed()));
|
|
||||||
}
|
|
||||||
|
|
||||||
if amount <= 0.0 {
|
|
||||||
return Err(ToolError::InvalidParameters(
|
|
||||||
"amount must be greater than zero".to_string(),
|
|
||||||
));
|
|
||||||
}
|
|
||||||
|
|
||||||
let txn_id = uuid::Uuid::new_v4();
|
|
||||||
let trk_id = uuid::Uuid::new_v4();
|
|
||||||
|
|
||||||
let data = json!({
|
|
||||||
"status": "success",
|
|
||||||
"data": {
|
|
||||||
"transaction_id": format!("txn_{}", &txn_id.to_string()[..8]),
|
|
||||||
"tracking_id": format!("trk_{}", &trk_id.to_string()[..8]),
|
|
||||||
"amount_usd": amount,
|
|
||||||
"completion_time": {
|
|
||||||
"min_calendar_days": 1,
|
|
||||||
"min_business_days": 1,
|
|
||||||
"max_calendar_days": 3,
|
|
||||||
"max_business_days": 2
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
Ok(ToolOutput::success(data, start.elapsed()))
|
|
||||||
}
|
|
||||||
|
|
||||||
fn requires_approval(&self, _params: &serde_json::Value) -> ApprovalRequirement {
|
|
||||||
ApprovalRequirement::UnlessAutoApproved
|
|
||||||
}
|
|
||||||
|
|
||||||
fn requires_sanitization(&self) -> bool {
|
|
||||||
false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Tool 4: Create Notification
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
/// Simulates sending a notification to the Abound system.
|
|
||||||
pub struct AboundCreateNotificationTool;
|
|
||||||
|
|
||||||
#[async_trait]
|
|
||||||
impl Tool for AboundCreateNotificationTool {
|
|
||||||
fn name(&self) -> &str {
|
|
||||||
"abound_create_notification"
|
|
||||||
}
|
|
||||||
|
|
||||||
fn description(&self) -> &str {
|
|
||||||
"Create a notification in the Abound app (e.g. rate alert, transfer \
|
|
||||||
confirmation, forex scoring signal). Returns 202 accepted."
|
|
||||||
}
|
|
||||||
|
|
||||||
fn parameters_schema(&self) -> serde_json::Value {
|
|
||||||
json!({
|
|
||||||
"type": "object",
|
|
||||||
"properties": {
|
|
||||||
"message_id": {
|
|
||||||
"type": "string",
|
|
||||||
"description": "Unique message identifier"
|
|
||||||
},
|
|
||||||
"action_type": {
|
|
||||||
"type": "string",
|
|
||||||
"description": "Notification type: 'notification' or 'token_refresh'"
|
|
||||||
},
|
|
||||||
"meta_data": {
|
|
||||||
"type": "object",
|
|
||||||
"description": "Additional metadata (e.g. score, rate, signal)"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"required": ["message_id", "action_type"]
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn execute(
|
|
||||||
&self,
|
|
||||||
params: serde_json::Value,
|
|
||||||
_ctx: &JobContext,
|
|
||||||
) -> Result<ToolOutput, ToolError> {
|
|
||||||
let start = Instant::now();
|
|
||||||
|
|
||||||
let message_id = require_str(¶ms, "message_id")?;
|
|
||||||
|
|
||||||
let data = json!({
|
|
||||||
"status": "accepted",
|
|
||||||
"message": "Notification request accepted for processing",
|
|
||||||
"data": {
|
|
||||||
"message_id": message_id
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
Ok(ToolOutput::success(data, start.elapsed()))
|
|
||||||
}
|
|
||||||
|
|
||||||
fn requires_sanitization(&self) -> bool {
|
|
||||||
false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Tool 5: Forex Score
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
/// Seasonal bias factors by month (1-indexed: Jan=1 .. Dec=12).
|
|
||||||
/// High bias in Oct-Mar (favorable remittance window), low Apr-Sep.
|
|
||||||
const SEASONAL_BIAS: [f64; 13] = [
|
|
||||||
0.0, // placeholder for 0-index
|
|
||||||
0.75, // Jan
|
|
||||||
0.70, // Feb
|
|
||||||
0.65, // Mar
|
|
||||||
0.35, // Apr
|
|
||||||
0.30, // May
|
|
||||||
0.25, // Jun
|
|
||||||
0.25, // Jul
|
|
||||||
0.30, // Aug
|
|
||||||
0.35, // Sep
|
|
||||||
0.70, // Oct
|
|
||||||
0.75, // Nov
|
|
||||||
0.65, // Dec
|
|
||||||
];
|
|
||||||
|
|
||||||
/// Computes a forex timing score for USD/INR, biased toward interesting
|
|
||||||
/// signals (60-80 range) for demo purposes.
|
|
||||||
pub struct AboundGetForexScoreTool;
|
|
||||||
|
|
||||||
#[async_trait]
|
|
||||||
impl Tool for AboundGetForexScoreTool {
|
|
||||||
fn name(&self) -> &str {
|
|
||||||
"abound_get_forex_score"
|
|
||||||
}
|
|
||||||
|
|
||||||
fn description(&self) -> &str {
|
|
||||||
"Compute a forex timing score (0-100) for USD/INR transfers. Returns \
|
|
||||||
a score with a signal: 'convert_now' (>=60, good time to send), \
|
|
||||||
'split_transfer' (40-59, send half now), or 'wait' (<40, hold off). \
|
|
||||||
Use this to advise users on optimal transfer timing."
|
|
||||||
}
|
|
||||||
|
|
||||||
fn parameters_schema(&self) -> serde_json::Value {
|
|
||||||
json!({
|
|
||||||
"type": "object",
|
|
||||||
"properties": {},
|
|
||||||
"required": []
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn execute(
|
|
||||||
&self,
|
|
||||||
_params: serde_json::Value,
|
|
||||||
_ctx: &JobContext,
|
|
||||||
) -> Result<ToolOutput, ToolError> {
|
|
||||||
let start = Instant::now();
|
|
||||||
|
|
||||||
let mut rng = rand::thread_rng();
|
|
||||||
|
|
||||||
// Mock current rate and MA50
|
|
||||||
let rate = mock_exchange_rate();
|
|
||||||
let ma50_jitter: f64 = rng.gen_range(-0.20..=0.20);
|
|
||||||
let ma50 = ((84.50 + ma50_jitter) * 100.0).round() / 100.0;
|
|
||||||
|
|
||||||
// Get seasonal bias for current month
|
|
||||||
let month = Utc::now().month() as usize;
|
|
||||||
let month_bias = SEASONAL_BIAS[month.clamp(1, 12)];
|
|
||||||
|
|
||||||
// Scoring weights
|
|
||||||
let w_ma = 0.7;
|
|
||||||
let w_s = 0.3;
|
|
||||||
|
|
||||||
// MA-based signal: how far current rate is above the 50-day average
|
|
||||||
let ma_signal = (50.0 + ((rate - ma50) / ma50 * 100.0) * 15.0).clamp(0.0, 100.0);
|
|
||||||
|
|
||||||
// Combined score
|
|
||||||
let raw_score = (ma_signal * w_ma + month_bias * 100.0 * w_s) / (w_ma + w_s);
|
|
||||||
|
|
||||||
// Bias toward 60-80 for demo (blend raw score with a favorable base)
|
|
||||||
let demo_base = 68.0;
|
|
||||||
let score = ((raw_score * 0.4 + demo_base * 0.6) as u32).clamp(55, 85);
|
|
||||||
|
|
||||||
let signal = if score >= 60 {
|
|
||||||
"convert_now"
|
|
||||||
} else if score >= 40 {
|
|
||||||
"split_transfer"
|
|
||||||
} else {
|
|
||||||
"wait"
|
|
||||||
};
|
|
||||||
|
|
||||||
let explanation = match signal {
|
|
||||||
"convert_now" => format!(
|
|
||||||
"The current USD/INR rate of {rate:.2} is above the 50-day moving average \
|
|
||||||
of {ma50:.2}, and seasonal trends are favorable. This is a good time to \
|
|
||||||
convert and send money."
|
|
||||||
),
|
|
||||||
"split_transfer" => format!(
|
|
||||||
"The rate of {rate:.2} is near the 50-day average of {ma50:.2}. Consider \
|
|
||||||
splitting your transfer \u{2014} send half now and hold the rest for a \
|
|
||||||
potentially better rate."
|
|
||||||
),
|
|
||||||
_ => format!(
|
|
||||||
"The current rate of {rate:.2} is below the 50-day average of {ma50:.2}. \
|
|
||||||
Unless urgent, consider waiting for a better rate."
|
|
||||||
),
|
|
||||||
};
|
|
||||||
|
|
||||||
let data = json!({
|
|
||||||
"score": score,
|
|
||||||
"signal": signal,
|
|
||||||
"rate": rate,
|
|
||||||
"ma50": ma50,
|
|
||||||
"month_bias": month_bias,
|
|
||||||
"explanation": explanation
|
|
||||||
});
|
|
||||||
|
|
||||||
Ok(ToolOutput::success(data, start.elapsed()))
|
|
||||||
}
|
|
||||||
|
|
||||||
fn requires_sanitization(&self) -> bool {
|
|
||||||
false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Tests
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use super::*;
|
|
||||||
use crate::context::JobContext;
|
|
||||||
|
|
||||||
fn test_ctx() -> JobContext {
|
|
||||||
JobContext::with_user("test-user", "test", "mock abound tool test")
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn account_info_returns_valid_json() {
|
|
||||||
let tool = AboundGetAccountInfoTool;
|
|
||||||
let result = tool.execute(json!({}), &test_ctx()).await.unwrap();
|
|
||||||
let data = &result.result["data"];
|
|
||||||
assert_eq!(data["user_id"], "acc_123456");
|
|
||||||
assert_eq!(data["limits"]["ach_limit"]["limit"], 5000);
|
|
||||||
assert_eq!(data["recipients"].as_array().unwrap().len(), 1);
|
|
||||||
assert_eq!(data["funding_sources"].as_array().unwrap().len(), 1);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn exchange_rate_returns_valid_range() {
|
|
||||||
let tool = AboundGetExchangeRateTool;
|
|
||||||
let result = tool.execute(json!({}), &test_ctx()).await.unwrap();
|
|
||||||
let rate = result.result["data"]["current_exchange_rate"]["value"]
|
|
||||||
.as_f64()
|
|
||||||
.unwrap();
|
|
||||||
// Rate should be within jitter range of base 85.42
|
|
||||||
assert!(rate > 85.0 && rate < 85.8, "rate {rate} out of range");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn send_wire_succeeds_within_limit() {
|
|
||||||
let tool = AboundSendWireTool;
|
|
||||||
let params = json!({
|
|
||||||
"funding_source_id": "fs_001",
|
|
||||||
"beneficiary_ref_id": "ben_001",
|
|
||||||
"amount": 1000.0,
|
|
||||||
"payment_reason_key": "FAMILY_MAINTENANCE"
|
|
||||||
});
|
|
||||||
let result = tool.execute(params, &test_ctx()).await.unwrap();
|
|
||||||
assert_eq!(result.result["status"], "success");
|
|
||||||
assert!(result.result["data"]["transaction_id"]
|
|
||||||
.as_str()
|
|
||||||
.unwrap()
|
|
||||||
.starts_with("txn_"));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn send_wire_rejects_over_limit() {
|
|
||||||
let tool = AboundSendWireTool;
|
|
||||||
let params = json!({
|
|
||||||
"funding_source_id": "fs_001",
|
|
||||||
"beneficiary_ref_id": "ben_001",
|
|
||||||
"amount": 6000.0,
|
|
||||||
"payment_reason_key": "FAMILY_MAINTENANCE"
|
|
||||||
});
|
|
||||||
let result = tool.execute(params, &test_ctx()).await.unwrap();
|
|
||||||
assert_eq!(result.result["status"], "error");
|
|
||||||
assert_eq!(result.result["error"]["code"], "TRANSFER_NOT_ALLOWED");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn send_wire_requires_approval() {
|
|
||||||
let tool = AboundSendWireTool;
|
|
||||||
assert!(matches!(
|
|
||||||
tool.requires_approval(&json!({})),
|
|
||||||
ApprovalRequirement::UnlessAutoApproved
|
|
||||||
));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn create_notification_returns_accepted() {
|
|
||||||
let tool = AboundCreateNotificationTool;
|
|
||||||
let params = json!({
|
|
||||||
"message_id": "msg_001",
|
|
||||||
"action_type": "notification",
|
|
||||||
"meta_data": { "score": 72 }
|
|
||||||
});
|
|
||||||
let result = tool.execute(params, &test_ctx()).await.unwrap();
|
|
||||||
assert_eq!(result.result["status"], "accepted");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn forex_score_in_demo_range() {
|
|
||||||
let tool = AboundGetForexScoreTool;
|
|
||||||
// Run multiple times to check range stability
|
|
||||||
for _ in 0..20 {
|
|
||||||
let result = tool.execute(json!({}), &test_ctx()).await.unwrap();
|
|
||||||
let score = result.result["score"].as_u64().unwrap();
|
|
||||||
assert!(
|
|
||||||
(55..=85).contains(&score),
|
|
||||||
"score {score} outside demo range [55, 85]"
|
|
||||||
);
|
|
||||||
let signal = result.result["signal"].as_str().unwrap();
|
|
||||||
assert!(
|
|
||||||
signal == "convert_now" || signal == "split_transfer" || signal == "wait",
|
|
||||||
"unexpected signal: {signal}"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,7 +1,5 @@
|
|||||||
//! Built-in tools that come with the agent.
|
//! Built-in tools that come with the agent.
|
||||||
|
|
||||||
#[cfg(feature = "demo")]
|
|
||||||
mod abound;
|
|
||||||
mod echo;
|
mod echo;
|
||||||
pub mod extension_tools;
|
pub mod extension_tools;
|
||||||
mod file;
|
mod file;
|
||||||
@@ -19,11 +17,6 @@ pub mod skill_tools;
|
|||||||
mod time;
|
mod time;
|
||||||
mod tool_info;
|
mod tool_info;
|
||||||
|
|
||||||
#[cfg(feature = "demo")]
|
|
||||||
pub use abound::{
|
|
||||||
AboundCreateNotificationTool, AboundGetAccountInfoTool, AboundGetExchangeRateTool,
|
|
||||||
AboundGetForexScoreTool, AboundSendWireTool,
|
|
||||||
};
|
|
||||||
pub use echo::EchoTool;
|
pub use echo::EchoTool;
|
||||||
pub use extension_tools::{
|
pub use extension_tools::{
|
||||||
ExtensionInfoTool, ToolActivateTool, ToolAuthTool, ToolInstallTool, ToolListTool,
|
ExtensionInfoTool, ToolActivateTool, ToolAuthTool, ToolInstallTool, ToolListTool,
|
||||||
|
|||||||
@@ -20,7 +20,8 @@ use uuid::Uuid;
|
|||||||
|
|
||||||
use crate::agent::routine::{
|
use crate::agent::routine::{
|
||||||
NotifyConfig, Routine, RoutineAction, RoutineGuardrails, Trigger, next_cron_fire,
|
NotifyConfig, Routine, RoutineAction, RoutineGuardrails, Trigger, next_cron_fire,
|
||||||
normalize_cron_expression,
|
normalize_cron_expression, reset_routine_verification_state, routine_verification_fingerprint,
|
||||||
|
routine_verification_status,
|
||||||
};
|
};
|
||||||
use crate::agent::routine_engine::RoutineEngine;
|
use crate::agent::routine_engine::RoutineEngine;
|
||||||
use crate::context::JobContext;
|
use crate::context::JobContext;
|
||||||
@@ -414,12 +415,29 @@ fn routine_create_tool_summary() -> ToolDiscoverySummary {
|
|||||||
"Set execution.use_tools=false to keep a new lightweight routine text-only.".into(),
|
"Set execution.use_tools=false to keep a new lightweight routine text-only.".into(),
|
||||||
"Omitting delivery.user falls back to the owner's last-seen notification target.".into(),
|
"Omitting delivery.user falls back to the owner's last-seen notification target.".into(),
|
||||||
"advanced.cooldown_secs defaults to 300.".into(),
|
"advanced.cooldown_secs defaults to 300.".into(),
|
||||||
|
"Creating a routine only saves the configuration. It does not prove the routine can execute successfully.".into(),
|
||||||
|
"After routine_create, tell the user the routine is unverified and offer to test it now unless they asked not to.".into(),
|
||||||
"Legacy flat aliases are still accepted for compatibility, but grouped fields are preferred.".into(),
|
"Legacy flat aliases are still accepted for compatibility, but grouped fields are preferred.".into(),
|
||||||
],
|
],
|
||||||
examples: routine_create_examples(),
|
examples: routine_create_examples(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn verification_result_payload(routine: &Routine, verification_reset: bool) -> Value {
|
||||||
|
let verification_status = routine_verification_status(routine);
|
||||||
|
serde_json::json!({
|
||||||
|
"verification_status": verification_status.as_str(),
|
||||||
|
"verification_reset": verification_reset,
|
||||||
|
"verification_hint": if verification_reset {
|
||||||
|
"The routine configuration changed and should be re-tested before being treated as reliable."
|
||||||
|
} else if verification_status == crate::agent::routine::RoutineVerificationStatus::Verified {
|
||||||
|
"The current routine configuration has already been verified with a successful run."
|
||||||
|
} else {
|
||||||
|
"The routine has been saved, but it has not been verified yet. Offer to test it now."
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
fn routine_create_schema(include_compatibility_aliases: bool) -> Value {
|
fn routine_create_schema(include_compatibility_aliases: bool) -> Value {
|
||||||
let mut schema = serde_json::json!({
|
let mut schema = serde_json::json!({
|
||||||
"type": "object",
|
"type": "object",
|
||||||
@@ -1063,7 +1081,8 @@ impl Tool for RoutineCreateTool {
|
|||||||
fn description(&self) -> &str {
|
fn description(&self) -> &str {
|
||||||
"Create a new routine (scheduled or event-driven task). \
|
"Create a new routine (scheduled or event-driven task). \
|
||||||
Supports cron schedules, event pattern matching, system events, and manual triggers. \
|
Supports cron schedules, event pattern matching, system events, and manual triggers. \
|
||||||
Use this when the user wants something to happen periodically or reactively."
|
Use this when the user wants something to happen periodically or reactively. \
|
||||||
|
Creation saves the routine, but does not verify that it will execute successfully."
|
||||||
}
|
}
|
||||||
|
|
||||||
fn requires_approval(&self, params: &serde_json::Value) -> ApprovalRequirement {
|
fn requires_approval(&self, params: &serde_json::Value) -> ApprovalRequirement {
|
||||||
@@ -1108,7 +1127,7 @@ impl Tool for RoutineCreateTool {
|
|||||||
None
|
None
|
||||||
};
|
};
|
||||||
|
|
||||||
let routine = Routine {
|
let mut routine = Routine {
|
||||||
id: Uuid::new_v4(),
|
id: Uuid::new_v4(),
|
||||||
name: normalized.name.clone(),
|
name: normalized.name.clone(),
|
||||||
description: normalized.description.clone(),
|
description: normalized.description.clone(),
|
||||||
@@ -1134,6 +1153,10 @@ impl Tool for RoutineCreateTool {
|
|||||||
created_at: Utc::now(),
|
created_at: Utc::now(),
|
||||||
updated_at: Utc::now(),
|
updated_at: Utc::now(),
|
||||||
};
|
};
|
||||||
|
routine.state = reset_routine_verification_state(
|
||||||
|
&routine.state,
|
||||||
|
routine_verification_fingerprint(&routine),
|
||||||
|
);
|
||||||
|
|
||||||
self.store
|
self.store
|
||||||
.create_routine(&routine)
|
.create_routine(&routine)
|
||||||
@@ -1148,12 +1171,14 @@ impl Tool for RoutineCreateTool {
|
|||||||
self.engine.refresh_event_cache().await;
|
self.engine.refresh_event_cache().await;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let verification = verification_result_payload(&routine, false);
|
||||||
let result = serde_json::json!({
|
let result = serde_json::json!({
|
||||||
"id": routine.id.to_string(),
|
"id": routine.id.to_string(),
|
||||||
"name": routine.name,
|
"name": routine.name.clone(),
|
||||||
"trigger_type": routine.trigger.type_tag(),
|
"trigger_type": routine.trigger.type_tag(),
|
||||||
"next_fire_at": routine.next_fire_at.map(|t| t.to_rfc3339()),
|
"next_fire_at": routine.next_fire_at.map(|t| t.to_rfc3339()),
|
||||||
"status": "created",
|
"status": "created",
|
||||||
|
"verification": verification,
|
||||||
});
|
});
|
||||||
|
|
||||||
Ok(ToolOutput::success(result, start.elapsed()))
|
Ok(ToolOutput::success(result, start.elapsed()))
|
||||||
@@ -1206,10 +1231,24 @@ impl Tool for RoutineListTool {
|
|||||||
.list_routines(&ctx.user_id)
|
.list_routines(&ctx.user_id)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| ToolError::ExecutionFailed(format!("failed to list routines: {e}")))?;
|
.map_err(|e| ToolError::ExecutionFailed(format!("failed to list routines: {e}")))?;
|
||||||
|
let routine_ids: Vec<Uuid> = routines.iter().map(|routine| routine.id).collect();
|
||||||
|
let last_run_statuses = self
|
||||||
|
.store
|
||||||
|
.batch_get_last_run_status(&routine_ids)
|
||||||
|
.await
|
||||||
|
.map_err(|e| {
|
||||||
|
ToolError::ExecutionFailed(format!("failed to read routine statuses: {e}"))
|
||||||
|
})?;
|
||||||
|
|
||||||
let list: Vec<serde_json::Value> = routines
|
let list: Vec<serde_json::Value> = routines
|
||||||
.iter()
|
.iter()
|
||||||
.map(|r| {
|
.map(|r| {
|
||||||
|
let verification_status = routine_verification_status(r);
|
||||||
|
let status = crate::agent::routine::routine_display_status_for_verification(
|
||||||
|
r,
|
||||||
|
verification_status,
|
||||||
|
last_run_statuses.get(&r.id).copied(),
|
||||||
|
);
|
||||||
serde_json::json!({
|
serde_json::json!({
|
||||||
"id": r.id.to_string(),
|
"id": r.id.to_string(),
|
||||||
"name": r.name,
|
"name": r.name,
|
||||||
@@ -1221,6 +1260,8 @@ impl Tool for RoutineListTool {
|
|||||||
"next_fire_at": r.next_fire_at.map(|t| t.to_rfc3339()),
|
"next_fire_at": r.next_fire_at.map(|t| t.to_rfc3339()),
|
||||||
"run_count": r.run_count,
|
"run_count": r.run_count,
|
||||||
"consecutive_failures": r.consecutive_failures,
|
"consecutive_failures": r.consecutive_failures,
|
||||||
|
"status": status.as_str(),
|
||||||
|
"verification_status": verification_status.as_str(),
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
.collect();
|
.collect();
|
||||||
@@ -1259,7 +1300,8 @@ impl Tool for RoutineUpdateTool {
|
|||||||
|
|
||||||
fn description(&self) -> &str {
|
fn description(&self) -> &str {
|
||||||
"Update an existing routine. Can change prompt, description, enabled state, cron schedule/timezone, \
|
"Update an existing routine. Can change prompt, description, enabled state, cron schedule/timezone, \
|
||||||
Pass the routine name and only the fields you want to change. This does not convert trigger types."
|
Pass the routine name and only the fields you want to change. This does not convert trigger types. \
|
||||||
|
Behavior-changing edits should leave the routine marked unverified until it is tested again."
|
||||||
}
|
}
|
||||||
|
|
||||||
fn parameters_schema(&self) -> serde_json::Value {
|
fn parameters_schema(&self) -> serde_json::Value {
|
||||||
@@ -1282,6 +1324,9 @@ impl Tool for RoutineUpdateTool {
|
|||||||
.map_err(|e| ToolError::ExecutionFailed(format!("DB error: {e}")))?
|
.map_err(|e| ToolError::ExecutionFailed(format!("DB error: {e}")))?
|
||||||
.ok_or_else(|| ToolError::ExecutionFailed(format!("routine '{}' not found", name)))?;
|
.ok_or_else(|| ToolError::ExecutionFailed(format!("routine '{}' not found", name)))?;
|
||||||
|
|
||||||
|
let original_fingerprint = routine_verification_fingerprint(&routine);
|
||||||
|
let mut verification_reset = false;
|
||||||
|
|
||||||
// Apply updates
|
// Apply updates
|
||||||
if let Some(enabled) = params.get("enabled").and_then(|v| v.as_bool()) {
|
if let Some(enabled) = params.get("enabled").and_then(|v| v.as_bool()) {
|
||||||
routine.enabled = enabled;
|
routine.enabled = enabled;
|
||||||
@@ -1293,8 +1338,18 @@ impl Tool for RoutineUpdateTool {
|
|||||||
|
|
||||||
if let Some(prompt) = params.get("prompt").and_then(|v| v.as_str()) {
|
if let Some(prompt) = params.get("prompt").and_then(|v| v.as_str()) {
|
||||||
match &mut routine.action {
|
match &mut routine.action {
|
||||||
RoutineAction::Lightweight { prompt: p, .. } => *p = prompt.to_string(),
|
RoutineAction::Lightweight { prompt: p, .. } => {
|
||||||
RoutineAction::FullJob { description: d, .. } => *d = prompt.to_string(),
|
if p != prompt {
|
||||||
|
verification_reset = true;
|
||||||
|
*p = prompt.to_string();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
RoutineAction::FullJob { description: d, .. } => {
|
||||||
|
if d != prompt {
|
||||||
|
verification_reset = true;
|
||||||
|
*d = prompt.to_string();
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1325,12 +1380,16 @@ impl Tool for RoutineUpdateTool {
|
|||||||
|
|
||||||
if let Some((old_schedule, old_tz)) = existing_cron {
|
if let Some((old_schedule, old_tz)) = existing_cron {
|
||||||
let effective_schedule = new_schedule.as_deref().unwrap_or(&old_schedule);
|
let effective_schedule = new_schedule.as_deref().unwrap_or(&old_schedule);
|
||||||
let effective_tz = new_timezone.or(old_tz);
|
let effective_tz = new_timezone.clone().or(old_tz.clone());
|
||||||
// Validate
|
// Validate
|
||||||
next_cron_fire(effective_schedule, effective_tz.as_deref()).map_err(|e| {
|
next_cron_fire(effective_schedule, effective_tz.as_deref()).map_err(|e| {
|
||||||
ToolError::InvalidParameters(format!("invalid cron schedule: {e}"))
|
ToolError::InvalidParameters(format!("invalid cron schedule: {e}"))
|
||||||
})?;
|
})?;
|
||||||
|
|
||||||
|
if effective_schedule != old_schedule || effective_tz != old_tz {
|
||||||
|
verification_reset = true;
|
||||||
|
}
|
||||||
|
|
||||||
routine.trigger = Trigger::Cron {
|
routine.trigger = Trigger::Cron {
|
||||||
schedule: effective_schedule.to_string(),
|
schedule: effective_schedule.to_string(),
|
||||||
timezone: effective_tz.clone(),
|
timezone: effective_tz.clone(),
|
||||||
@@ -1344,6 +1403,12 @@ impl Tool for RoutineUpdateTool {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let updated_fingerprint = routine_verification_fingerprint(&routine);
|
||||||
|
if updated_fingerprint != original_fingerprint {
|
||||||
|
verification_reset = true;
|
||||||
|
routine.state = reset_routine_verification_state(&routine.state, updated_fingerprint);
|
||||||
|
}
|
||||||
|
|
||||||
self.store
|
self.store
|
||||||
.update_routine(&routine)
|
.update_routine(&routine)
|
||||||
.await
|
.await
|
||||||
@@ -1352,12 +1417,14 @@ impl Tool for RoutineUpdateTool {
|
|||||||
// Refresh event cache in case trigger changed
|
// Refresh event cache in case trigger changed
|
||||||
self.engine.refresh_event_cache().await;
|
self.engine.refresh_event_cache().await;
|
||||||
|
|
||||||
|
let verification = verification_result_payload(&routine, verification_reset);
|
||||||
let result = serde_json::json!({
|
let result = serde_json::json!({
|
||||||
"name": routine.name,
|
"name": routine.name.clone(),
|
||||||
"enabled": routine.enabled,
|
"enabled": routine.enabled,
|
||||||
"trigger_type": routine.trigger.type_tag(),
|
"trigger_type": routine.trigger.type_tag(),
|
||||||
"next_fire_at": routine.next_fire_at.map(|t| t.to_rfc3339()),
|
"next_fire_at": routine.next_fire_at.map(|t| t.to_rfc3339()),
|
||||||
"status": "updated",
|
"status": "updated",
|
||||||
|
"verification": verification,
|
||||||
});
|
});
|
||||||
|
|
||||||
Ok(ToolOutput::success(result, start.elapsed()))
|
Ok(ToolOutput::success(result, start.elapsed()))
|
||||||
|
|||||||
@@ -16,11 +16,6 @@ use crate::skills::registry::SkillRegistry;
|
|||||||
use crate::tools::builder::{
|
use crate::tools::builder::{
|
||||||
BuildSoftwareTool, BuilderConfig, LlmSoftwareBuilder, SoftwareBuilder,
|
BuildSoftwareTool, BuilderConfig, LlmSoftwareBuilder, SoftwareBuilder,
|
||||||
};
|
};
|
||||||
#[cfg(feature = "demo")]
|
|
||||||
use crate::tools::builtin::{
|
|
||||||
AboundCreateNotificationTool, AboundGetAccountInfoTool, AboundGetExchangeRateTool,
|
|
||||||
AboundGetForexScoreTool, AboundSendWireTool,
|
|
||||||
};
|
|
||||||
use crate::tools::builtin::{
|
use crate::tools::builtin::{
|
||||||
ApplyPatchTool, CancelJobTool, CreateJobTool, EchoTool, ExtensionInfoTool, HttpTool,
|
ApplyPatchTool, CancelJobTool, CreateJobTool, EchoTool, ExtensionInfoTool, HttpTool,
|
||||||
JobEventsTool, JobPromptTool, JobStatusTool, JsonTool, ListDirTool, ListJobsTool,
|
JobEventsTool, JobPromptTool, JobStatusTool, JsonTool, ListDirTool, ListJobsTool,
|
||||||
@@ -253,16 +248,6 @@ impl ToolRegistry {
|
|||||||
}
|
}
|
||||||
self.register_sync(Arc::new(http));
|
self.register_sync(Arc::new(http));
|
||||||
|
|
||||||
// Abound demo tools (mock APIs — only compiled with --features demo)
|
|
||||||
#[cfg(feature = "demo")]
|
|
||||||
{
|
|
||||||
self.register_sync(Arc::new(AboundGetAccountInfoTool));
|
|
||||||
self.register_sync(Arc::new(AboundGetExchangeRateTool));
|
|
||||||
self.register_sync(Arc::new(AboundSendWireTool));
|
|
||||||
self.register_sync(Arc::new(AboundCreateNotificationTool));
|
|
||||||
self.register_sync(Arc::new(AboundGetForexScoreTool));
|
|
||||||
}
|
|
||||||
|
|
||||||
tracing::debug!("Registered {} built-in tools", self.count());
|
tracing::debug!("Registered {} built-in tools", self.count());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -428,8 +428,10 @@ mod tests {
|
|||||||
host: "127.0.0.1".to_string(),
|
host: "127.0.0.1".to_string(),
|
||||||
port: 3000,
|
port: 3000,
|
||||||
auth_token: None,
|
auth_token: None,
|
||||||
|
user_id: "test".to_string(),
|
||||||
workspace_read_scopes: Vec::new(),
|
workspace_read_scopes: Vec::new(),
|
||||||
memory_layers: Vec::new(),
|
memory_layers: Vec::new(),
|
||||||
|
user_tokens: None,
|
||||||
});
|
});
|
||||||
c
|
c
|
||||||
}
|
}
|
||||||
@@ -440,8 +442,10 @@ mod tests {
|
|||||||
host: host.to_string(),
|
host: host.to_string(),
|
||||||
port,
|
port,
|
||||||
auth_token: None,
|
auth_token: None,
|
||||||
|
user_id: "test".to_string(),
|
||||||
workspace_read_scopes: Vec::new(),
|
workspace_read_scopes: Vec::new(),
|
||||||
memory_layers: Vec::new(),
|
memory_layers: Vec::new(),
|
||||||
|
user_tokens: None,
|
||||||
});
|
});
|
||||||
c
|
c
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -271,6 +271,7 @@ async def ironclaw_server(
|
|||||||
"GATEWAY_HOST": "127.0.0.1",
|
"GATEWAY_HOST": "127.0.0.1",
|
||||||
"GATEWAY_PORT": str(gateway_port),
|
"GATEWAY_PORT": str(gateway_port),
|
||||||
"GATEWAY_AUTH_TOKEN": AUTH_TOKEN,
|
"GATEWAY_AUTH_TOKEN": AUTH_TOKEN,
|
||||||
|
"GATEWAY_USER_ID": "e2e-web-sender",
|
||||||
"HTTP_HOST": "127.0.0.1",
|
"HTTP_HOST": "127.0.0.1",
|
||||||
"HTTP_PORT": str(http_port),
|
"HTTP_PORT": str(http_port),
|
||||||
"HTTP_WEBHOOK_SECRET": HTTP_WEBHOOK_SECRET,
|
"HTTP_WEBHOOK_SECRET": HTTP_WEBHOOK_SECRET,
|
||||||
@@ -370,6 +371,7 @@ async def hosted_oauth_refresh_server(
|
|||||||
"GATEWAY_HOST": "127.0.0.1",
|
"GATEWAY_HOST": "127.0.0.1",
|
||||||
"GATEWAY_PORT": str(gateway_port),
|
"GATEWAY_PORT": str(gateway_port),
|
||||||
"GATEWAY_AUTH_TOKEN": AUTH_TOKEN,
|
"GATEWAY_AUTH_TOKEN": AUTH_TOKEN,
|
||||||
|
"GATEWAY_USER_ID": OWNER_SCOPE_ID,
|
||||||
"HTTP_HOST": "127.0.0.1",
|
"HTTP_HOST": "127.0.0.1",
|
||||||
"HTTP_PORT": str(http_port),
|
"HTTP_PORT": str(http_port),
|
||||||
"HTTP_WEBHOOK_SECRET": HTTP_WEBHOOK_SECRET,
|
"HTTP_WEBHOOK_SECRET": HTTP_WEBHOOK_SECRET,
|
||||||
@@ -409,6 +411,7 @@ async def hosted_oauth_refresh_server(
|
|||||||
yield {
|
yield {
|
||||||
"base_url": base_url,
|
"base_url": base_url,
|
||||||
"db_path": db_path,
|
"db_path": db_path,
|
||||||
|
"gateway_user_id": OWNER_SCOPE_ID,
|
||||||
"mock_llm_url": mock_llm_server,
|
"mock_llm_url": mock_llm_server,
|
||||||
}
|
}
|
||||||
except TimeoutError:
|
except TimeoutError:
|
||||||
@@ -470,6 +473,7 @@ async def http_channel_server_without_secret(
|
|||||||
"GATEWAY_HOST": "127.0.0.1",
|
"GATEWAY_HOST": "127.0.0.1",
|
||||||
"GATEWAY_PORT": str(gateway_port),
|
"GATEWAY_PORT": str(gateway_port),
|
||||||
"GATEWAY_AUTH_TOKEN": AUTH_TOKEN,
|
"GATEWAY_AUTH_TOKEN": AUTH_TOKEN,
|
||||||
|
"GATEWAY_USER_ID": "e2e-tester",
|
||||||
"HTTP_HOST": "127.0.0.1",
|
"HTTP_HOST": "127.0.0.1",
|
||||||
"HTTP_PORT": str(http_port),
|
"HTTP_PORT": str(http_port),
|
||||||
"CLI_ENABLED": "false",
|
"CLI_ENABLED": "false",
|
||||||
|
|||||||
@@ -29,7 +29,7 @@
|
|||||||
{
|
{
|
||||||
"response": {
|
"response": {
|
||||||
"type": "text",
|
"type": "text",
|
||||||
"content": "Created the any-channel-bug-watcher routine for bug messages.",
|
"content": "Created the any-channel-bug-watcher routine for bug messages, but it is not verified yet. It should stay unverified until it has a successful run.",
|
||||||
"input_tokens": 170,
|
"input_tokens": 170,
|
||||||
"output_tokens": 18
|
"output_tokens": 18
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -30,7 +30,7 @@
|
|||||||
{
|
{
|
||||||
"response": {
|
"response": {
|
||||||
"type": "text",
|
"type": "text",
|
||||||
"content": "Created the telegram-bug-watcher routine for Telegram bug messages.",
|
"content": "Created the telegram-bug-watcher routine for Telegram bug messages, but it is not verified yet. I can test it the next time you want to fire it.",
|
||||||
"input_tokens": 180,
|
"input_tokens": 180,
|
||||||
"output_tokens": 20
|
"output_tokens": 20
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -37,7 +37,7 @@
|
|||||||
{
|
{
|
||||||
"response": {
|
"response": {
|
||||||
"type": "text",
|
"type": "text",
|
||||||
"content": "Created the **morning-tech-news** routine with manual trigger and full_job mode. The `message` and `http` tools are pre-authorized.",
|
"content": "Created the **morning-tech-news** routine with manual trigger and full_job mode. The `message` and `http` tools are available, but the routine is not verified yet.",
|
||||||
"input_tokens": 200,
|
"input_tokens": 200,
|
||||||
"output_tokens": 50
|
"output_tokens": 50
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -57,7 +57,7 @@
|
|||||||
{
|
{
|
||||||
"response": {
|
"response": {
|
||||||
"type": "text",
|
"type": "text",
|
||||||
"content": "Created the weekday-digest routine with a grouped cron request and listed the active routines.",
|
"content": "Created the weekday-digest routine with a grouped cron request and listed the routines. It is not verified yet, so it should stay unverified until it has a successful run.",
|
||||||
"input_tokens": 250,
|
"input_tokens": 250,
|
||||||
"output_tokens": 24
|
"output_tokens": 24
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -52,7 +52,7 @@
|
|||||||
{
|
{
|
||||||
"response": {
|
"response": {
|
||||||
"type": "text",
|
"type": "text",
|
||||||
"content": "I created a daily-check routine that runs at 9 AM every day. The routine list shows it as active.",
|
"content": "I created the daily-check routine, but it is not verified yet. It is scheduled for 9 AM every day, and the routine list should show it as unverified until it has a successful run.",
|
||||||
"input_tokens": 300,
|
"input_tokens": 300,
|
||||||
"output_tokens": 25
|
"output_tokens": 25
|
||||||
}
|
}
|
||||||
|
|||||||
+1
-1
@@ -41,7 +41,7 @@
|
|||||||
{
|
{
|
||||||
"response": {
|
"response": {
|
||||||
"type": "text",
|
"type": "text",
|
||||||
"content": "The history-test routine was created. Its run history is empty since it hasn't been triggered yet.",
|
"content": "The history-test routine was created, but it is not verified yet. Its run history is empty since it hasn't been triggered yet.",
|
||||||
"input_tokens": 300,
|
"input_tokens": 300,
|
||||||
"output_tokens": 25
|
"output_tokens": 25
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -27,7 +27,7 @@
|
|||||||
{
|
{
|
||||||
"response": {
|
"response": {
|
||||||
"type": "text",
|
"type": "text",
|
||||||
"content": "Created the manual-triage routine. It will only run when explicitly fired.",
|
"content": "Created the manual-triage routine, but it is not verified yet. It will only run when explicitly fired, so I can test it for you when you're ready.",
|
||||||
"input_tokens": 140,
|
"input_tokens": 140,
|
||||||
"output_tokens": 18
|
"output_tokens": 18
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -30,7 +30,7 @@
|
|||||||
{
|
{
|
||||||
"response": {
|
"response": {
|
||||||
"type": "text",
|
"type": "text",
|
||||||
"content": "Created the manual-triage-no-tools routine. It will only run when explicitly fired and stay text-only.",
|
"content": "Created the manual-triage-no-tools routine, but it is not verified yet. It will only run when explicitly fired and stay text-only until you decide to test it.",
|
||||||
"input_tokens": 140,
|
"input_tokens": 140,
|
||||||
"output_tokens": 18
|
"output_tokens": 18
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -59,7 +59,7 @@
|
|||||||
{
|
{
|
||||||
"response": {
|
"response": {
|
||||||
"type": "text",
|
"type": "text",
|
||||||
"content": "Created, updated, and then deleted the temp-routine successfully.",
|
"content": "Created, updated, and then deleted the temp-routine successfully. The update would have left it unverified until it was tested again.",
|
||||||
"input_tokens": 400,
|
"input_tokens": 400,
|
||||||
"output_tokens": 20
|
"output_tokens": 20
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ mod tests {
|
|||||||
use chrono::Utc;
|
use chrono::Utc;
|
||||||
use ironclaw::agent::routine::{
|
use ironclaw::agent::routine::{
|
||||||
NotifyConfig, Routine, RoutineAction, RoutineGuardrails, Trigger,
|
NotifyConfig, Routine, RoutineAction, RoutineGuardrails, Trigger,
|
||||||
|
reset_routine_verification_state, routine_verification_fingerprint,
|
||||||
};
|
};
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
@@ -338,4 +339,115 @@ mod tests {
|
|||||||
harness.shutdown().await;
|
harness.shutdown().await;
|
||||||
mock.shutdown().await;
|
mock.shutdown().await;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn routines_api_surfaces_unverified_status_for_new_routine() {
|
||||||
|
let mock = MockOpenAiServerBuilder::new()
|
||||||
|
.with_default_response(MockOpenAiResponse::Text("ack".to_string()))
|
||||||
|
.start()
|
||||||
|
.await;
|
||||||
|
|
||||||
|
let harness =
|
||||||
|
GatewayWorkflowHarness::start_openai_compatible(&mock.openai_base_url(), "mock-model")
|
||||||
|
.await;
|
||||||
|
|
||||||
|
let mut routine = Routine {
|
||||||
|
id: Uuid::new_v4(),
|
||||||
|
name: "wf-unverified".to_string(),
|
||||||
|
description: "Unverified status regression test".to_string(),
|
||||||
|
user_id: harness.user_id.clone(),
|
||||||
|
enabled: true,
|
||||||
|
trigger: Trigger::Manual,
|
||||||
|
action: RoutineAction::Lightweight {
|
||||||
|
prompt: "Check verification status".to_string(),
|
||||||
|
context_paths: Vec::new(),
|
||||||
|
max_tokens: 512,
|
||||||
|
use_tools: false,
|
||||||
|
max_tool_rounds: 1,
|
||||||
|
},
|
||||||
|
guardrails: RoutineGuardrails {
|
||||||
|
cooldown: Duration::from_secs(0),
|
||||||
|
max_concurrent: 1,
|
||||||
|
dedup_window: None,
|
||||||
|
},
|
||||||
|
notify: NotifyConfig::default(),
|
||||||
|
last_run_at: None,
|
||||||
|
next_fire_at: None,
|
||||||
|
run_count: 0,
|
||||||
|
consecutive_failures: 0,
|
||||||
|
state: serde_json::json!({}),
|
||||||
|
created_at: Utc::now(),
|
||||||
|
updated_at: Utc::now(),
|
||||||
|
};
|
||||||
|
routine.state = reset_routine_verification_state(
|
||||||
|
&routine.state,
|
||||||
|
routine_verification_fingerprint(&routine),
|
||||||
|
);
|
||||||
|
harness
|
||||||
|
.db
|
||||||
|
.create_routine(&routine)
|
||||||
|
.await
|
||||||
|
.expect("create routine");
|
||||||
|
|
||||||
|
let mut disabled_routine = routine.clone();
|
||||||
|
disabled_routine.id = Uuid::new_v4();
|
||||||
|
disabled_routine.name = "wf-unverified-disabled".to_string();
|
||||||
|
disabled_routine.enabled = false;
|
||||||
|
disabled_routine.state = reset_routine_verification_state(
|
||||||
|
&disabled_routine.state,
|
||||||
|
routine_verification_fingerprint(&disabled_routine),
|
||||||
|
);
|
||||||
|
harness
|
||||||
|
.db
|
||||||
|
.create_routine(&disabled_routine)
|
||||||
|
.await
|
||||||
|
.expect("create disabled routine");
|
||||||
|
|
||||||
|
let list = harness.list_routines().await;
|
||||||
|
let routine_id = routine.id.to_string();
|
||||||
|
let listed = list["routines"]
|
||||||
|
.as_array()
|
||||||
|
.expect("routines array")
|
||||||
|
.iter()
|
||||||
|
.find(|item| item["id"].as_str() == Some(routine_id.as_str()))
|
||||||
|
.expect("routine should be listed");
|
||||||
|
assert_eq!(listed["status"].as_str(), Some("unverified"));
|
||||||
|
assert_eq!(listed["verification_status"].as_str(), Some("unverified"));
|
||||||
|
|
||||||
|
let summary = harness
|
||||||
|
.client
|
||||||
|
.get(format!("{}/api/routines/summary", harness.base_url()))
|
||||||
|
.bearer_auth(&harness.auth_token)
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.expect("summary request failed")
|
||||||
|
.error_for_status()
|
||||||
|
.expect("summary non-2xx")
|
||||||
|
.json::<serde_json::Value>()
|
||||||
|
.await
|
||||||
|
.expect("invalid summary response");
|
||||||
|
assert_eq!(summary["unverified"].as_u64(), Some(2));
|
||||||
|
|
||||||
|
let detail = harness
|
||||||
|
.client
|
||||||
|
.get(format!(
|
||||||
|
"{}/api/routines/{}",
|
||||||
|
harness.base_url(),
|
||||||
|
routine_id
|
||||||
|
))
|
||||||
|
.bearer_auth(&harness.auth_token)
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.expect("detail request failed")
|
||||||
|
.error_for_status()
|
||||||
|
.expect("detail non-2xx")
|
||||||
|
.json::<serde_json::Value>()
|
||||||
|
.await
|
||||||
|
.expect("invalid detail response");
|
||||||
|
assert_eq!(detail["status"].as_str(), Some("unverified"));
|
||||||
|
assert_eq!(detail["verification_status"].as_str(), Some("unverified"));
|
||||||
|
|
||||||
|
harness.shutdown().await;
|
||||||
|
mock.shutdown().await;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -42,6 +42,8 @@ const ALICE_USER_ID: &str = "alice";
|
|||||||
const BOB_USER_ID: &str = "bob";
|
const BOB_USER_ID: &str = "bob";
|
||||||
const OWNER_TOKEN: &str = "tok-owner-secret";
|
const OWNER_TOKEN: &str = "tok-owner-secret";
|
||||||
const OWNER_SCOPE_ID: &str = "owner-scope";
|
const OWNER_SCOPE_ID: &str = "owner-scope";
|
||||||
|
const GATEWAY_SENDER_ID: &str = "gateway-sender";
|
||||||
|
|
||||||
/// Build a MultiAuthState with two users.
|
/// Build a MultiAuthState with two users.
|
||||||
fn two_user_auth() -> MultiAuthState {
|
fn two_user_auth() -> MultiAuthState {
|
||||||
let mut tokens = HashMap::new();
|
let mut tokens = HashMap::new();
|
||||||
@@ -49,7 +51,6 @@ fn two_user_auth() -> MultiAuthState {
|
|||||||
ALICE_TOKEN.to_string(),
|
ALICE_TOKEN.to_string(),
|
||||||
UserIdentity {
|
UserIdentity {
|
||||||
user_id: ALICE_USER_ID.to_string(),
|
user_id: ALICE_USER_ID.to_string(),
|
||||||
role: "admin".to_string(),
|
|
||||||
workspace_read_scopes: Vec::new(),
|
workspace_read_scopes: Vec::new(),
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
@@ -57,7 +58,6 @@ fn two_user_auth() -> MultiAuthState {
|
|||||||
BOB_TOKEN.to_string(),
|
BOB_TOKEN.to_string(),
|
||||||
UserIdentity {
|
UserIdentity {
|
||||||
user_id: BOB_USER_ID.to_string(),
|
user_id: BOB_USER_ID.to_string(),
|
||||||
role: "admin".to_string(),
|
|
||||||
workspace_read_scopes: vec!["shared".to_string()],
|
workspace_read_scopes: vec!["shared".to_string()],
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
@@ -79,10 +79,7 @@ fn user_echo_app(auth: MultiAuthState) -> Router {
|
|||||||
.route("/api/whoami/scopes", get(echo_user_with_scopes))
|
.route("/api/whoami/scopes", get(echo_user_with_scopes))
|
||||||
.route("/api/action", post(echo_user))
|
.route("/api/action", post(echo_user))
|
||||||
.route("/api/chat/events", get(echo_user)) // SSE endpoint (allows query token)
|
.route("/api/chat/events", get(echo_user)) // SSE endpoint (allows query token)
|
||||||
.layer(middleware::from_fn_with_state(
|
.layer(middleware::from_fn_with_state(auth, auth_middleware))
|
||||||
ironclaw::channels::web::auth::CombinedAuthState::from(auth),
|
|
||||||
auth_middleware,
|
|
||||||
))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ===========================================================================
|
// ===========================================================================
|
||||||
@@ -547,6 +544,7 @@ fn gateway_state_has_multi_tenant_fields() {
|
|||||||
prompt_queue: None,
|
prompt_queue: None,
|
||||||
scheduler: None,
|
scheduler: None,
|
||||||
owner_id: "fallback".to_string(),
|
owner_id: "fallback".to_string(),
|
||||||
|
default_sender_id: "fallback".to_string(),
|
||||||
shutdown_tx: tokio::sync::RwLock::new(None),
|
shutdown_tx: tokio::sync::RwLock::new(None),
|
||||||
ws_tracker: Some(Arc::new(WsConnectionTracker::new())),
|
ws_tracker: Some(Arc::new(WsConnectionTracker::new())),
|
||||||
llm_provider: None,
|
llm_provider: None,
|
||||||
@@ -560,10 +558,10 @@ fn gateway_state_has_multi_tenant_fields() {
|
|||||||
startup_time: std::time::Instant::now(),
|
startup_time: std::time::Instant::now(),
|
||||||
webhook_rate_limiter: RateLimiter::new(10, 60),
|
webhook_rate_limiter: RateLimiter::new(10, 60),
|
||||||
active_config: Default::default(),
|
active_config: Default::default(),
|
||||||
secrets_store: None,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
assert_eq!(state.owner_id, "fallback");
|
assert_eq!(state.owner_id, "fallback");
|
||||||
|
assert_eq!(state.default_sender_id, "fallback");
|
||||||
assert!(state.workspace_pool.is_none());
|
assert!(state.workspace_pool.is_none());
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -594,7 +592,6 @@ async fn start_owner_scoped_sender_server() -> (
|
|||||||
OWNER_TOKEN.to_string(),
|
OWNER_TOKEN.to_string(),
|
||||||
UserIdentity {
|
UserIdentity {
|
||||||
user_id: OWNER_SCOPE_ID.to_string(),
|
user_id: OWNER_SCOPE_ID.to_string(),
|
||||||
role: "admin".to_string(),
|
|
||||||
workspace_read_scopes: Vec::new(),
|
workspace_read_scopes: Vec::new(),
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
@@ -602,7 +599,6 @@ async fn start_owner_scoped_sender_server() -> (
|
|||||||
BOB_TOKEN.to_string(),
|
BOB_TOKEN.to_string(),
|
||||||
UserIdentity {
|
UserIdentity {
|
||||||
user_id: BOB_USER_ID.to_string(),
|
user_id: BOB_USER_ID.to_string(),
|
||||||
role: "member".to_string(),
|
|
||||||
workspace_read_scopes: Vec::new(),
|
workspace_read_scopes: Vec::new(),
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
@@ -622,6 +618,7 @@ async fn start_owner_scoped_sender_server() -> (
|
|||||||
prompt_queue: None,
|
prompt_queue: None,
|
||||||
scheduler: None,
|
scheduler: None,
|
||||||
owner_id: OWNER_SCOPE_ID.to_string(),
|
owner_id: OWNER_SCOPE_ID.to_string(),
|
||||||
|
default_sender_id: GATEWAY_SENDER_ID.to_string(),
|
||||||
shutdown_tx: tokio::sync::RwLock::new(None),
|
shutdown_tx: tokio::sync::RwLock::new(None),
|
||||||
ws_tracker: Some(Arc::new(WsConnectionTracker::new())),
|
ws_tracker: Some(Arc::new(WsConnectionTracker::new())),
|
||||||
llm_provider: None,
|
llm_provider: None,
|
||||||
@@ -635,10 +632,9 @@ async fn start_owner_scoped_sender_server() -> (
|
|||||||
routine_engine: Arc::new(tokio::sync::RwLock::new(None)),
|
routine_engine: Arc::new(tokio::sync::RwLock::new(None)),
|
||||||
startup_time: std::time::Instant::now(),
|
startup_time: std::time::Instant::now(),
|
||||||
active_config: Default::default(),
|
active_config: Default::default(),
|
||||||
secrets_store: None,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
let auth = MultiAuthState::multi(tokens).into();
|
let auth = MultiAuthState::multi(tokens);
|
||||||
let addr: SocketAddr = "127.0.0.1:0".parse().unwrap();
|
let addr: SocketAddr = "127.0.0.1:0".parse().unwrap();
|
||||||
let bound = start_server(addr, state.clone(), auth)
|
let bound = start_server(addr, state.clone(), auth)
|
||||||
.await
|
.await
|
||||||
@@ -773,7 +769,7 @@ async fn full_server_chat_send_rewrites_sender_only_for_owner_scope_rebind() {
|
|||||||
.expect("Timed out waiting for owner message")
|
.expect("Timed out waiting for owner message")
|
||||||
.expect("Agent channel closed");
|
.expect("Agent channel closed");
|
||||||
assert_eq!(owner_msg.user_id, OWNER_SCOPE_ID);
|
assert_eq!(owner_msg.user_id, OWNER_SCOPE_ID);
|
||||||
assert_eq!(owner_msg.sender_id, OWNER_SCOPE_ID);
|
assert_eq!(owner_msg.sender_id, GATEWAY_SENDER_ID);
|
||||||
assert_eq!(owner_msg.content, "hello from owner");
|
assert_eq!(owner_msg.content, "hello from owner");
|
||||||
|
|
||||||
let other_resp = client
|
let other_resp = client
|
||||||
@@ -1007,6 +1003,7 @@ async fn start_multi_user_server_with_db() -> (
|
|||||||
prompt_queue: None,
|
prompt_queue: None,
|
||||||
scheduler: None,
|
scheduler: None,
|
||||||
owner_id: ALICE_USER_ID.to_string(),
|
owner_id: ALICE_USER_ID.to_string(),
|
||||||
|
default_sender_id: ALICE_USER_ID.to_string(),
|
||||||
shutdown_tx: tokio::sync::RwLock::new(None),
|
shutdown_tx: tokio::sync::RwLock::new(None),
|
||||||
ws_tracker: Some(Arc::new(WsConnectionTracker::new())),
|
ws_tracker: Some(Arc::new(WsConnectionTracker::new())),
|
||||||
llm_provider: None,
|
llm_provider: None,
|
||||||
@@ -1020,11 +1017,10 @@ async fn start_multi_user_server_with_db() -> (
|
|||||||
startup_time: std::time::Instant::now(),
|
startup_time: std::time::Instant::now(),
|
||||||
webhook_rate_limiter: RateLimiter::new(10, 60),
|
webhook_rate_limiter: RateLimiter::new(10, 60),
|
||||||
active_config: Default::default(),
|
active_config: Default::default(),
|
||||||
secrets_store: None,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
let addr: SocketAddr = "127.0.0.1:0".parse().unwrap();
|
let addr: SocketAddr = "127.0.0.1:0".parse().unwrap();
|
||||||
let bound = ironclaw::channels::web::server::start_server(addr, state.clone(), auth.into())
|
let bound = ironclaw::channels::web::server::start_server(addr, state.clone(), auth)
|
||||||
.await
|
.await
|
||||||
.expect("Failed to start server with DB");
|
.expect("Failed to start server with DB");
|
||||||
|
|
||||||
|
|||||||
@@ -205,6 +205,7 @@ async fn start_test_server_with_provider(
|
|||||||
prompt_queue: None,
|
prompt_queue: None,
|
||||||
scheduler: None,
|
scheduler: None,
|
||||||
owner_id: "test-user".to_string(),
|
owner_id: "test-user".to_string(),
|
||||||
|
default_sender_id: "test-user".to_string(),
|
||||||
shutdown_tx: tokio::sync::RwLock::new(None),
|
shutdown_tx: tokio::sync::RwLock::new(None),
|
||||||
ws_tracker: Some(Arc::new(WsConnectionTracker::new())),
|
ws_tracker: Some(Arc::new(WsConnectionTracker::new())),
|
||||||
llm_provider: Some(llm_provider),
|
llm_provider: Some(llm_provider),
|
||||||
@@ -218,7 +219,6 @@ async fn start_test_server_with_provider(
|
|||||||
routine_engine: Arc::new(tokio::sync::RwLock::new(None)),
|
routine_engine: Arc::new(tokio::sync::RwLock::new(None)),
|
||||||
startup_time: std::time::Instant::now(),
|
startup_time: std::time::Instant::now(),
|
||||||
active_config: ironclaw::channels::web::server::ActiveConfigSnapshot::default(),
|
active_config: ironclaw::channels::web::server::ActiveConfigSnapshot::default(),
|
||||||
secrets_store: None,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
let auth = ironclaw::channels::web::auth::MultiAuthState::single(
|
let auth = ironclaw::channels::web::auth::MultiAuthState::single(
|
||||||
@@ -226,7 +226,7 @@ async fn start_test_server_with_provider(
|
|||||||
"test-user".to_string(),
|
"test-user".to_string(),
|
||||||
);
|
);
|
||||||
let addr: SocketAddr = "127.0.0.1:0".parse().unwrap();
|
let addr: SocketAddr = "127.0.0.1:0".parse().unwrap();
|
||||||
let bound_addr = start_server(addr, state.clone(), auth.into())
|
let bound_addr = start_server(addr, state.clone(), auth)
|
||||||
.await
|
.await
|
||||||
.expect("Failed to start test server");
|
.expect("Failed to start test server");
|
||||||
|
|
||||||
@@ -704,6 +704,7 @@ async fn test_no_llm_provider_returns_503() {
|
|||||||
prompt_queue: None,
|
prompt_queue: None,
|
||||||
scheduler: None,
|
scheduler: None,
|
||||||
owner_id: "test-user".to_string(),
|
owner_id: "test-user".to_string(),
|
||||||
|
default_sender_id: "test-user".to_string(),
|
||||||
shutdown_tx: tokio::sync::RwLock::new(None),
|
shutdown_tx: tokio::sync::RwLock::new(None),
|
||||||
ws_tracker: Some(Arc::new(WsConnectionTracker::new())),
|
ws_tracker: Some(Arc::new(WsConnectionTracker::new())),
|
||||||
llm_provider: None, // No LLM!
|
llm_provider: None, // No LLM!
|
||||||
@@ -717,7 +718,6 @@ async fn test_no_llm_provider_returns_503() {
|
|||||||
routine_engine: Arc::new(tokio::sync::RwLock::new(None)),
|
routine_engine: Arc::new(tokio::sync::RwLock::new(None)),
|
||||||
startup_time: std::time::Instant::now(),
|
startup_time: std::time::Instant::now(),
|
||||||
active_config: ironclaw::channels::web::server::ActiveConfigSnapshot::default(),
|
active_config: ironclaw::channels::web::server::ActiveConfigSnapshot::default(),
|
||||||
secrets_store: None,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
let auth = ironclaw::channels::web::auth::MultiAuthState::single(
|
let auth = ironclaw::channels::web::auth::MultiAuthState::single(
|
||||||
@@ -725,7 +725,7 @@ async fn test_no_llm_provider_returns_503() {
|
|||||||
"test-user".to_string(),
|
"test-user".to_string(),
|
||||||
);
|
);
|
||||||
let addr: SocketAddr = "127.0.0.1:0".parse().unwrap();
|
let addr: SocketAddr = "127.0.0.1:0".parse().unwrap();
|
||||||
let bound_addr = start_server(addr, state, auth.into()).await.unwrap();
|
let bound_addr = start_server(addr, state, auth).await.unwrap();
|
||||||
|
|
||||||
let url = format!("http://{}/v1/chat/completions", bound_addr);
|
let url = format!("http://{}/v1/chat/completions", bound_addr);
|
||||||
let resp = client()
|
let resp = client()
|
||||||
@@ -763,7 +763,7 @@ async fn test_chat_completions_body_too_large() {
|
|||||||
post(ironclaw::channels::web::openai_compat::chat_completions_handler),
|
post(ironclaw::channels::web::openai_compat::chat_completions_handler),
|
||||||
)
|
)
|
||||||
.route_layer(middleware::from_fn_with_state(
|
.route_layer(middleware::from_fn_with_state(
|
||||||
ironclaw::channels::web::auth::CombinedAuthState::from(auth_state),
|
auth_state,
|
||||||
ironclaw::channels::web::auth::auth_middleware,
|
ironclaw::channels::web::auth::auth_middleware,
|
||||||
))
|
))
|
||||||
.layer(DefaultBodyLimit::max(10 * 1024 * 1024))
|
.layer(DefaultBodyLimit::max(10 * 1024 * 1024))
|
||||||
|
|||||||
@@ -227,6 +227,7 @@ impl GatewayWorkflowHarness {
|
|||||||
prompt_queue: None,
|
prompt_queue: None,
|
||||||
scheduler: Some(scheduler_slot.clone()),
|
scheduler: Some(scheduler_slot.clone()),
|
||||||
owner_id: user_id.clone(),
|
owner_id: user_id.clone(),
|
||||||
|
default_sender_id: user_id.clone(),
|
||||||
shutdown_tx: tokio::sync::RwLock::new(None),
|
shutdown_tx: tokio::sync::RwLock::new(None),
|
||||||
ws_tracker: Some(Arc::new(WsConnectionTracker::new())),
|
ws_tracker: Some(Arc::new(WsConnectionTracker::new())),
|
||||||
llm_provider: Some(Arc::clone(&components.llm)),
|
llm_provider: Some(Arc::clone(&components.llm)),
|
||||||
@@ -240,7 +241,6 @@ impl GatewayWorkflowHarness {
|
|||||||
routine_engine: Arc::clone(&routine_slot),
|
routine_engine: Arc::clone(&routine_slot),
|
||||||
startup_time: Instant::now(),
|
startup_time: Instant::now(),
|
||||||
active_config: ironclaw::channels::web::server::ActiveConfigSnapshot::default(),
|
active_config: ironclaw::channels::web::server::ActiveConfigSnapshot::default(),
|
||||||
secrets_store: None,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
let mut agent = Agent::new(
|
let mut agent = Agent::new(
|
||||||
@@ -299,7 +299,7 @@ impl GatewayWorkflowHarness {
|
|||||||
let addr = start_server(
|
let addr = start_server(
|
||||||
"127.0.0.1:0".parse().expect("valid localhost addr"),
|
"127.0.0.1:0".parse().expect("valid localhost addr"),
|
||||||
Arc::clone(&gateway_state),
|
Arc::clone(&gateway_state),
|
||||||
auth.into(),
|
auth,
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
.expect("failed to start gateway server");
|
.expect("failed to start gateway server");
|
||||||
|
|||||||
@@ -52,6 +52,7 @@ async fn start_test_server() -> (
|
|||||||
prompt_queue: None,
|
prompt_queue: None,
|
||||||
scheduler: None,
|
scheduler: None,
|
||||||
owner_id: "test-user".to_string(),
|
owner_id: "test-user".to_string(),
|
||||||
|
default_sender_id: "test-user".to_string(),
|
||||||
shutdown_tx: tokio::sync::RwLock::new(None),
|
shutdown_tx: tokio::sync::RwLock::new(None),
|
||||||
ws_tracker: Some(Arc::new(WsConnectionTracker::new())),
|
ws_tracker: Some(Arc::new(WsConnectionTracker::new())),
|
||||||
llm_provider: None,
|
llm_provider: None,
|
||||||
@@ -65,7 +66,6 @@ async fn start_test_server() -> (
|
|||||||
routine_engine: Arc::new(tokio::sync::RwLock::new(None)),
|
routine_engine: Arc::new(tokio::sync::RwLock::new(None)),
|
||||||
startup_time: std::time::Instant::now(),
|
startup_time: std::time::Instant::now(),
|
||||||
active_config: ironclaw::channels::web::server::ActiveConfigSnapshot::default(),
|
active_config: ironclaw::channels::web::server::ActiveConfigSnapshot::default(),
|
||||||
secrets_store: None,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
let auth = ironclaw::channels::web::auth::MultiAuthState::single(
|
let auth = ironclaw::channels::web::auth::MultiAuthState::single(
|
||||||
@@ -73,7 +73,7 @@ async fn start_test_server() -> (
|
|||||||
"test-user".to_string(),
|
"test-user".to_string(),
|
||||||
);
|
);
|
||||||
let addr: SocketAddr = "127.0.0.1:0".parse().unwrap();
|
let addr: SocketAddr = "127.0.0.1:0".parse().unwrap();
|
||||||
let bound_addr = start_server(addr, state.clone(), auth.into())
|
let bound_addr = start_server(addr, state.clone(), auth)
|
||||||
.await
|
.await
|
||||||
.expect("Failed to start test server");
|
.expect("Failed to start test server");
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user