Skip to main content

roder_protocol/
hosted.rs

1//! `hosted/*` protocol DTOs (roadmap phase 72, Task 1).
2//!
3//! Contract-first: these DTOs and the method manifest entries define the
4//! hosted multi-tenant surface before the gateway implementation lands
5//! (phase 72 Task 2). Tenant identity is always resolved server-side from
6//! validated credentials — no request DTO here accepts a caller-supplied
7//! tenant id for core operations.
8
9use serde::{Deserialize, Serialize};
10
11pub use roder_api::hosted_hooks::{
12    HookDelivery, HookDeliveryStatus, HookId, HookRetryPolicy, HookScope, HostedHookDefinition,
13};
14pub use roder_api::identity::{
15    HostedRequestContext, HostedRole, HostedScope, PrincipalContext, ServiceAccountId,
16    TenantContext, TenantId,
17};
18
19/// `hosted/whoami` — the resolved identity of the calling credential.
20#[derive(Debug, Clone, Serialize, Deserialize)]
21#[serde(rename_all = "camelCase")]
22pub struct HostedWhoamiResult {
23    pub context: HostedRequestContext,
24}
25
26#[derive(Debug, Clone, Serialize, Deserialize)]
27#[serde(rename_all = "camelCase")]
28pub struct HostedTenantsListResult {
29    /// Tenants the principal belongs to (system admins see all).
30    pub tenants: Vec<TenantContext>,
31}
32
33#[derive(Debug, Clone, Serialize, Deserialize)]
34#[serde(rename_all = "camelCase")]
35pub struct HostedTenantReadParams {
36    /// Admin-surface read of a tenant the caller can administer; absent =
37    /// the caller's own tenant.
38    #[serde(default, skip_serializing_if = "Option::is_none")]
39    pub tenant_id: Option<TenantId>,
40}
41
42#[derive(Debug, Clone, Serialize, Deserialize)]
43#[serde(rename_all = "camelCase")]
44pub struct HostedTenantReadResult {
45    pub tenant: TenantContext,
46    pub member_count: u64,
47    pub service_account_count: u64,
48}
49
50#[derive(Debug, Clone, Serialize, Deserialize)]
51#[serde(rename_all = "camelCase")]
52pub struct HostedServiceAccountSummary {
53    pub service_account_id: ServiceAccountId,
54    pub display_name: String,
55    pub scopes: Vec<HostedScope>,
56    /// Stable credential id for audit correlation; never key material.
57    pub credential_id: String,
58    pub revoked: bool,
59}
60
61#[derive(Debug, Clone, Serialize, Deserialize)]
62#[serde(rename_all = "camelCase")]
63pub struct HostedServiceAccountsListResult {
64    pub service_accounts: Vec<HostedServiceAccountSummary>,
65}
66
67#[derive(Debug, Clone, Serialize, Deserialize)]
68#[serde(rename_all = "camelCase")]
69pub struct HostedServiceAccountCreateParams {
70    pub display_name: String,
71    pub scopes: Vec<HostedScope>,
72}
73
74/// The API key secret appears exactly once, in this response; the server
75/// stores only its hash.
76#[derive(Debug, Clone, Serialize, Deserialize)]
77#[serde(rename_all = "camelCase")]
78pub struct HostedServiceAccountCreateResult {
79    pub service_account: HostedServiceAccountSummary,
80    pub api_key_once: String,
81}
82
83#[derive(Debug, Clone, Serialize, Deserialize)]
84#[serde(rename_all = "camelCase")]
85pub struct HostedServiceAccountRevokeParams {
86    pub service_account_id: ServiceAccountId,
87}
88
89#[derive(Debug, Clone, Serialize, Deserialize)]
90#[serde(rename_all = "camelCase")]
91pub struct HostedServiceAccountRevokeResult {
92    pub service_account_id: ServiceAccountId,
93    pub revoked: bool,
94}
95
96#[derive(Debug, Clone, Serialize, Deserialize)]
97#[serde(rename_all = "camelCase")]
98pub struct HostedHooksListResult {
99    pub hooks: Vec<HostedHookDefinition>,
100}
101
102#[derive(Debug, Clone, Serialize, Deserialize)]
103#[serde(rename_all = "camelCase")]
104pub struct HostedHookCreateParams {
105    pub scope: HookScope,
106    pub event_kinds: Vec<String>,
107    pub url: String,
108    #[serde(default, skip_serializing_if = "Option::is_none")]
109    pub signing_secret_ref: Option<String>,
110    #[serde(default)]
111    pub retry: Option<HookRetryPolicy>,
112}
113
114#[derive(Debug, Clone, Serialize, Deserialize)]
115#[serde(rename_all = "camelCase")]
116pub struct HostedHookUpdateParams {
117    pub hook_id: HookId,
118    #[serde(default, skip_serializing_if = "Option::is_none")]
119    pub enabled: Option<bool>,
120    #[serde(default, skip_serializing_if = "Option::is_none")]
121    pub event_kinds: Option<Vec<String>>,
122    #[serde(default, skip_serializing_if = "Option::is_none")]
123    pub url: Option<String>,
124}
125
126#[derive(Debug, Clone, Serialize, Deserialize)]
127#[serde(rename_all = "camelCase")]
128pub struct HostedHookDeleteParams {
129    pub hook_id: HookId,
130}
131
132#[derive(Debug, Clone, Serialize, Deserialize)]
133#[serde(rename_all = "camelCase")]
134pub struct HostedHookResult {
135    pub hook: HostedHookDefinition,
136}
137
138#[derive(Debug, Clone, Serialize, Deserialize)]
139#[serde(rename_all = "camelCase")]
140pub struct HostedAuditListParams {
141    #[serde(default, skip_serializing_if = "Option::is_none")]
142    pub since_ms: Option<i64>,
143    #[serde(default, skip_serializing_if = "Option::is_none")]
144    pub limit: Option<u64>,
145}
146
147/// A redaction-safe audit row: coarse action, actor/tenant context, and an
148/// outcome — never request bodies or credentials.
149#[derive(Debug, Clone, Serialize, Deserialize)]
150#[serde(rename_all = "camelCase")]
151pub struct HostedAuditRecord {
152    pub action: String,
153    pub outcome: String,
154    pub tenant_id: TenantId,
155    pub principal: PrincipalContext,
156    pub timestamp_ms: i64,
157}
158
159#[derive(Debug, Clone, Serialize, Deserialize)]
160#[serde(rename_all = "camelCase")]
161pub struct HostedAuditListResult {
162    pub records: Vec<HostedAuditRecord>,
163}
164
165#[derive(Debug, Clone, Serialize, Deserialize)]
166#[serde(rename_all = "camelCase")]
167pub struct HostedUsageReadParams {
168    #[serde(default, skip_serializing_if = "Option::is_none")]
169    pub since_ms: Option<i64>,
170    #[serde(default, skip_serializing_if = "Option::is_none")]
171    pub until_ms: Option<i64>,
172}
173
174#[derive(Debug, Clone, Serialize, Deserialize)]
175#[serde(rename_all = "camelCase")]
176pub struct HostedUsageReadResult {
177    pub tenant_id: TenantId,
178    pub turn_count: u64,
179    pub tool_call_count: u64,
180    pub total_tokens: u64,
181}
182
183#[cfg(test)]
184mod tests {
185    use super::*;
186    use time::OffsetDateTime;
187
188    #[test]
189    fn hosted_dtos_use_camel_case_and_round_trip() {
190        let whoami = HostedWhoamiResult {
191            context: HostedRequestContext {
192                tenant: TenantContext {
193                    tenant_id: "tenant-a".to_string(),
194                    display_name: None,
195                },
196                principal: PrincipalContext::User {
197                    user_id: "user-1".to_string(),
198                    display_name: Some("Dev".to_string()),
199                },
200                role: HostedRole::TenantAdmin,
201                scopes: vec![HostedScope::Admin],
202                credential_id: Some("key-9".to_string()),
203                authenticated_at: OffsetDateTime::UNIX_EPOCH,
204            },
205        };
206        let json = serde_json::to_value(&whoami).unwrap();
207        assert_eq!(json["context"]["tenant"]["tenantId"], "tenant-a");
208        assert_eq!(json["context"]["role"], "tenant_admin");
209
210        // Core thread/turn DTOs never accept tenant ids; only hosted admin
211        // DTOs reference tenants, and only for admin reads.
212        let params: HostedTenantReadParams = serde_json::from_value(serde_json::json!({})).unwrap();
213        assert!(params.tenant_id.is_none());
214
215        let create: HostedHookCreateParams = serde_json::from_value(serde_json::json!({
216            "scope": "tenant",
217            "eventKinds": ["turn."],
218            "url": "https://hooks.example.test/x"
219        }))
220        .unwrap();
221        assert_eq!(create.scope, HookScope::Tenant);
222        assert!(create.retry.is_none());
223    }
224}