+13
api/migrations/010_oauth_clients.sql
+13
api/migrations/010_oauth_clients.sql
···
1
+
-- Create oauth_clients table
2
+
CREATE TABLE oauth_clients (
3
+
id SERIAL PRIMARY KEY,
4
+
slice_uri TEXT NOT NULL,
5
+
client_id TEXT UNIQUE NOT NULL,
6
+
registration_access_token TEXT,
7
+
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
8
+
created_by_did TEXT NOT NULL
9
+
);
10
+
11
+
-- Create indexes for efficient lookups
12
+
CREATE INDEX idx_oauth_clients_slice_uri ON oauth_clients (slice_uri);
13
+
CREATE INDEX idx_oauth_clients_client_id ON oauth_clients (client_id);
+112
api/scripts/generate_typescript.ts
+112
api/scripts/generate_typescript.ts
···
666
666
},
667
667
],
668
668
});
669
+
670
+
// OAuth client interfaces
671
+
sourceFile.addInterface({
672
+
name: "CreateOAuthClientRequest",
673
+
isExported: true,
674
+
properties: [
675
+
{ name: "clientName", type: "string" },
676
+
{ name: "redirectUris", type: "string[]" },
677
+
{ name: "grantTypes", type: "string[]", hasQuestionToken: true },
678
+
{ name: "responseTypes", type: "string[]", hasQuestionToken: true },
679
+
{ name: "scope", type: "string", hasQuestionToken: true },
680
+
{ name: "clientUri", type: "string", hasQuestionToken: true },
681
+
{ name: "logoUri", type: "string", hasQuestionToken: true },
682
+
{ name: "tosUri", type: "string", hasQuestionToken: true },
683
+
{ name: "policyUri", type: "string", hasQuestionToken: true },
684
+
],
685
+
});
686
+
687
+
sourceFile.addInterface({
688
+
name: "OAuthClientDetails",
689
+
isExported: true,
690
+
properties: [
691
+
{ name: "clientId", type: "string" },
692
+
{ name: "clientSecret", type: "string", hasQuestionToken: true },
693
+
{ name: "clientName", type: "string" },
694
+
{ name: "redirectUris", type: "string[]" },
695
+
{ name: "grantTypes", type: "string[]" },
696
+
{ name: "responseTypes", type: "string[]" },
697
+
{ name: "scope", type: "string", hasQuestionToken: true },
698
+
{ name: "clientUri", type: "string", hasQuestionToken: true },
699
+
{ name: "logoUri", type: "string", hasQuestionToken: true },
700
+
{ name: "tosUri", type: "string", hasQuestionToken: true },
701
+
{ name: "policyUri", type: "string", hasQuestionToken: true },
702
+
{ name: "createdAt", type: "string" },
703
+
{ name: "createdByDid", type: "string" },
704
+
],
705
+
});
706
+
707
+
sourceFile.addInterface({
708
+
name: "ListOAuthClientsResponse",
709
+
isExported: true,
710
+
properties: [
711
+
{ name: "clients", type: "OAuthClientDetails[]" },
712
+
],
713
+
});
714
+
715
+
sourceFile.addInterface({
716
+
name: "UpdateOAuthClientRequest",
717
+
isExported: true,
718
+
properties: [
719
+
{ name: "clientId", type: "string" },
720
+
{ name: "clientName", type: "string", hasQuestionToken: true },
721
+
{ name: "redirectUris", type: "string[]", hasQuestionToken: true },
722
+
{ name: "scope", type: "string", hasQuestionToken: true },
723
+
{ name: "clientUri", type: "string", hasQuestionToken: true },
724
+
{ name: "logoUri", type: "string", hasQuestionToken: true },
725
+
{ name: "tosUri", type: "string", hasQuestionToken: true },
726
+
{ name: "policyUri", type: "string", hasQuestionToken: true },
727
+
],
728
+
});
729
+
730
+
sourceFile.addInterface({
731
+
name: "DeleteOAuthClientResponse",
732
+
isExported: true,
733
+
properties: [
734
+
{ name: "success", type: "boolean" },
735
+
{ name: "message", type: "string" },
736
+
],
737
+
});
669
738
}
670
739
671
740
// Convert lexicon type to TypeScript type
···
1702
1771
statements: [
1703
1772
`const requestParams = { slice: this.sliceUri, ...params };`,
1704
1773
`return await this.makeRequest<SyncUserCollectionsResult>('social.slices.slice.syncUserCollections', 'POST', requestParams);`,
1774
+
],
1775
+
});
1776
+
1777
+
// Add OAuth client management methods
1778
+
classDeclaration.addMethod({
1779
+
name: "createOAuthClient",
1780
+
parameters: [{ name: "params", type: "CreateOAuthClientRequest" }],
1781
+
returnType: "Promise<OAuthClientDetails>",
1782
+
isAsync: true,
1783
+
statements: [
1784
+
`const requestParams = { ...params, sliceUri: this.sliceUri };`,
1785
+
`return await this.makeRequest<OAuthClientDetails>('social.slices.slice.createOAuthClient', 'POST', requestParams);`,
1786
+
],
1787
+
});
1788
+
1789
+
classDeclaration.addMethod({
1790
+
name: "getOAuthClients",
1791
+
returnType: "Promise<ListOAuthClientsResponse>",
1792
+
isAsync: true,
1793
+
statements: [
1794
+
`const requestParams = { slice: this.sliceUri };`,
1795
+
`return await this.makeRequest<ListOAuthClientsResponse>('social.slices.slice.getOAuthClients', 'GET', requestParams);`,
1796
+
],
1797
+
});
1798
+
1799
+
classDeclaration.addMethod({
1800
+
name: "updateOAuthClient",
1801
+
parameters: [{ name: "params", type: "UpdateOAuthClientRequest" }],
1802
+
returnType: "Promise<OAuthClientDetails>",
1803
+
isAsync: true,
1804
+
statements: [
1805
+
`const requestParams = { ...params, sliceUri: this.sliceUri };`,
1806
+
`return await this.makeRequest<OAuthClientDetails>('social.slices.slice.updateOAuthClient', 'POST', requestParams);`,
1807
+
],
1808
+
});
1809
+
1810
+
classDeclaration.addMethod({
1811
+
name: "deleteOAuthClient",
1812
+
parameters: [{ name: "clientId", type: "string" }],
1813
+
returnType: "Promise<DeleteOAuthClientResponse>",
1814
+
isAsync: true,
1815
+
statements: [
1816
+
`return await this.makeRequest<DeleteOAuthClientResponse>('social.slices.slice.deleteOAuthClient', 'POST', { clientId });`,
1705
1817
],
1706
1818
});
1707
1819
}
+77
-1
api/src/database.rs
+77
-1
api/src/database.rs
···
2
2
use base64::{Engine as _, engine::general_purpose};
3
3
4
4
use crate::errors::DatabaseError;
5
-
use crate::models::{Actor, CollectionStats, IndexedRecord, Record, WhereCondition, WhereClause, SortField};
5
+
use crate::models::{Actor, CollectionStats, IndexedRecord, Record, WhereCondition, WhereClause, SortField, OAuthClient};
6
6
use std::collections::HashMap;
7
7
8
8
···
1072
1072
.await?;
1073
1073
1074
1074
Ok(row.and_then(|r| r.domain))
1075
+
}
1076
+
1077
+
pub async fn create_oauth_client(
1078
+
&self,
1079
+
slice_uri: &str,
1080
+
client_id: &str,
1081
+
registration_access_token: Option<&str>,
1082
+
created_by_did: &str,
1083
+
) -> Result<OAuthClient, DatabaseError> {
1084
+
let client = sqlx::query_as!(
1085
+
OAuthClient,
1086
+
r#"
1087
+
INSERT INTO oauth_clients (slice_uri, client_id, registration_access_token, created_by_did)
1088
+
VALUES ($1, $2, $3, $4)
1089
+
RETURNING id, slice_uri, client_id, registration_access_token, created_at as "created_at!", created_by_did
1090
+
"#,
1091
+
slice_uri,
1092
+
client_id,
1093
+
registration_access_token,
1094
+
created_by_did
1095
+
)
1096
+
.fetch_one(&self.pool)
1097
+
.await?;
1098
+
1099
+
Ok(client)
1100
+
}
1101
+
1102
+
pub async fn get_oauth_clients_for_slice(&self, slice_uri: &str) -> Result<Vec<OAuthClient>, DatabaseError> {
1103
+
let clients = sqlx::query_as!(
1104
+
OAuthClient,
1105
+
r#"
1106
+
SELECT id, slice_uri, client_id, registration_access_token, created_at as "created_at!", created_by_did
1107
+
FROM oauth_clients
1108
+
WHERE slice_uri = $1
1109
+
ORDER BY created_at DESC
1110
+
"#,
1111
+
slice_uri
1112
+
)
1113
+
.fetch_all(&self.pool)
1114
+
.await?;
1115
+
1116
+
Ok(clients)
1117
+
}
1118
+
1119
+
pub async fn get_oauth_client_by_id(&self, client_id: &str) -> Result<Option<OAuthClient>, DatabaseError> {
1120
+
let client = sqlx::query_as!(
1121
+
OAuthClient,
1122
+
r#"
1123
+
SELECT id, slice_uri, client_id, registration_access_token, created_at as "created_at!", created_by_did
1124
+
FROM oauth_clients
1125
+
WHERE client_id = $1
1126
+
"#,
1127
+
client_id
1128
+
)
1129
+
.fetch_optional(&self.pool)
1130
+
.await?;
1131
+
1132
+
Ok(client)
1133
+
}
1134
+
1135
+
pub async fn delete_oauth_client(&self, client_id: &str) -> Result<(), DatabaseError> {
1136
+
let result = sqlx::query!(
1137
+
r#"
1138
+
DELETE FROM oauth_clients
1139
+
WHERE client_id = $1
1140
+
"#,
1141
+
client_id
1142
+
)
1143
+
.execute(&self.pool)
1144
+
.await?;
1145
+
1146
+
if result.rows_affected() == 0 {
1147
+
return Err(DatabaseError::RecordNotFound { uri: client_id.to_string() });
1148
+
}
1149
+
1150
+
Ok(())
1075
1151
}
1076
1152
1077
1153
}
+33
api/src/errors.rs
+33
api/src/errors.rs
···
1
1
use thiserror::Error;
2
+
use axum::{
3
+
http::StatusCode,
4
+
response::{IntoResponse, Response},
5
+
Json,
6
+
};
2
7
3
8
4
9
#[derive(Error, Debug)]
···
44
49
45
50
#[error("error-slice-app-3 Server bind failed: {0}")]
46
51
ServerBind(#[from] std::io::Error),
52
+
53
+
#[error("error-slice-app-4 Internal server error: {0}")]
54
+
Internal(String),
55
+
56
+
#[error("error-slice-app-5 Resource not found: {0}")]
57
+
NotFound(String),
58
+
59
+
#[error("error-slice-app-6 Bad request: {0}")]
60
+
BadRequest(String),
47
61
}
48
62
49
63
#[derive(Error, Debug)]
···
56
70
57
71
}
58
72
73
+
impl IntoResponse for AppError {
74
+
fn into_response(self) -> Response {
75
+
let (status, error_message) = match self {
76
+
AppError::BadRequest(msg) => (StatusCode::BAD_REQUEST, msg),
77
+
AppError::NotFound(msg) => (StatusCode::NOT_FOUND, msg),
78
+
AppError::Internal(msg) => (StatusCode::INTERNAL_SERVER_ERROR, msg),
79
+
AppError::DatabaseConnection(e) => (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()),
80
+
AppError::Migration(e) => (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()),
81
+
AppError::ServerBind(e) => (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()),
82
+
};
83
+
84
+
let body = Json(serde_json::json!({
85
+
"error": error_message
86
+
}));
87
+
88
+
(status, body).into_response()
89
+
}
90
+
}
91
+
+488
api/src/handler_oauth_clients.rs
+488
api/src/handler_oauth_clients.rs
···
1
+
use axum::{
2
+
extract::{Query, State},
3
+
http::HeaderMap,
4
+
response::Json,
5
+
Json as ExtractJson,
6
+
};
7
+
use serde::{Deserialize, Serialize};
8
+
use reqwest::Client;
9
+
10
+
use crate::{
11
+
AppState,
12
+
auth,
13
+
errors::AppError,
14
+
models::{
15
+
CreateOAuthClientRequest, OAuthClientDetails, ListOAuthClientsResponse,
16
+
UpdateOAuthClientRequest, DeleteOAuthClientResponse
17
+
},
18
+
};
19
+
20
+
#[derive(Deserialize)]
21
+
#[serde(rename_all = "camelCase")]
22
+
pub struct GetOAuthClientsQuery {
23
+
pub slice: String,
24
+
}
25
+
26
+
#[derive(Deserialize)]
27
+
#[serde(rename_all = "camelCase")]
28
+
pub struct DeleteOAuthClientRequest {
29
+
pub client_id: String,
30
+
}
31
+
32
+
#[derive(Debug, Serialize, Deserialize)]
33
+
#[serde(rename_all = "snake_case")]
34
+
struct AipClientRegistrationRequest {
35
+
pub client_name: String,
36
+
pub redirect_uris: Vec<String>,
37
+
#[serde(skip_serializing_if = "Option::is_none")]
38
+
pub grant_types: Option<Vec<String>>,
39
+
#[serde(skip_serializing_if = "Option::is_none")]
40
+
pub response_types: Option<Vec<String>>,
41
+
#[serde(skip_serializing_if = "Option::is_none")]
42
+
pub scope: Option<String>,
43
+
#[serde(skip_serializing_if = "Option::is_none")]
44
+
pub client_uri: Option<String>,
45
+
#[serde(skip_serializing_if = "Option::is_none")]
46
+
pub logo_uri: Option<String>,
47
+
#[serde(skip_serializing_if = "Option::is_none")]
48
+
pub tos_uri: Option<String>,
49
+
#[serde(skip_serializing_if = "Option::is_none")]
50
+
pub policy_uri: Option<String>,
51
+
}
52
+
53
+
#[derive(Serialize, Deserialize)]
54
+
#[serde(rename_all = "snake_case")]
55
+
struct AipClientRegistrationResponse {
56
+
pub client_id: String,
57
+
#[serde(skip_serializing_if = "Option::is_none")]
58
+
pub client_secret: Option<String>,
59
+
#[serde(skip_serializing_if = "Option::is_none")]
60
+
pub registration_access_token: Option<String>,
61
+
pub client_name: String,
62
+
pub redirect_uris: Vec<String>,
63
+
pub grant_types: Vec<String>,
64
+
pub response_types: Vec<String>,
65
+
#[serde(skip_serializing_if = "Option::is_none")]
66
+
pub scope: Option<String>,
67
+
#[serde(skip_serializing_if = "Option::is_none")]
68
+
pub client_uri: Option<String>,
69
+
#[serde(skip_serializing_if = "Option::is_none")]
70
+
pub logo_uri: Option<String>,
71
+
#[serde(skip_serializing_if = "Option::is_none")]
72
+
pub tos_uri: Option<String>,
73
+
#[serde(skip_serializing_if = "Option::is_none")]
74
+
pub policy_uri: Option<String>,
75
+
// Additional fields that AIP returns but we don't need to send
76
+
#[serde(skip_serializing_if = "Option::is_none")]
77
+
pub token_endpoint_auth_method: Option<String>,
78
+
#[serde(skip_serializing_if = "Option::is_none")]
79
+
pub registration_client_uri: Option<String>,
80
+
#[serde(skip_serializing_if = "Option::is_none")]
81
+
pub client_id_issued_at: Option<i64>,
82
+
#[serde(skip_serializing_if = "Option::is_none")]
83
+
pub client_secret_expires_at: Option<i64>,
84
+
}
85
+
86
+
pub async fn create_oauth_client(
87
+
State(state): State<AppState>,
88
+
headers: HeaderMap,
89
+
ExtractJson(request): ExtractJson<CreateOAuthClientRequest>,
90
+
) -> Result<Json<OAuthClientDetails>, AppError> {
91
+
// Debug log the incoming request
92
+
tracing::debug!("Incoming OAuth client registration request: {:?}", request);
93
+
94
+
// Validate request
95
+
if request.client_name.trim().is_empty() {
96
+
return Err(AppError::BadRequest("Client name cannot be empty".to_string()));
97
+
}
98
+
99
+
if request.redirect_uris.is_empty() {
100
+
return Err(AppError::BadRequest("At least one redirect URI is required".to_string()));
101
+
}
102
+
103
+
// Validate redirect URIs have basic URL format
104
+
for uri in &request.redirect_uris {
105
+
if !uri.starts_with("http://") && !uri.starts_with("https://") {
106
+
return Err(AppError::BadRequest(format!("Redirect URI must use HTTP or HTTPS: {}", uri)));
107
+
}
108
+
if uri.trim().is_empty() {
109
+
return Err(AppError::BadRequest("Redirect URI cannot be empty".to_string()));
110
+
}
111
+
}
112
+
113
+
// Extract and verify authentication
114
+
let token = auth::extract_bearer_token(&headers)
115
+
.map_err(|_| AppError::BadRequest("Missing or invalid Authorization header".to_string()))?;
116
+
let user_info = auth::verify_oauth_token(&token, &state.config.auth_base_url).await
117
+
.map_err(|_| AppError::BadRequest("Invalid or expired access token".to_string()))?;
118
+
119
+
let user_did = user_info.sub;
120
+
121
+
// Register client with AIP
122
+
let aip_base_url = &state.config.auth_base_url;
123
+
124
+
let client = Client::new();
125
+
let aip_request = AipClientRegistrationRequest {
126
+
client_name: request.client_name.clone(),
127
+
redirect_uris: request.redirect_uris.clone(),
128
+
grant_types: request.grant_types.clone(),
129
+
response_types: request.response_types.clone(),
130
+
scope: request.scope.clone(),
131
+
client_uri: request.client_uri.clone(),
132
+
logo_uri: request.logo_uri.clone(),
133
+
tos_uri: request.tos_uri.clone(),
134
+
policy_uri: request.policy_uri.clone(),
135
+
};
136
+
137
+
let registration_url = format!("{}/oauth/clients/register", aip_base_url);
138
+
tracing::debug!("Attempting to register OAuth client at: {}", registration_url);
139
+
tracing::debug!("Sending AIP request: {:?}", aip_request);
140
+
141
+
let aip_response = client
142
+
.post(®istration_url)
143
+
.json(&aip_request)
144
+
.send()
145
+
.await
146
+
.map_err(|e| AppError::Internal(format!("Failed to register client with AIP: {}", e)))?;
147
+
148
+
let status = aip_response.status();
149
+
tracing::debug!("AIP registration response status: {}", status);
150
+
151
+
if !status.is_success() {
152
+
let error_text = aip_response.text().await.unwrap_or_else(|_| "Unknown error".to_string());
153
+
tracing::error!("AIP registration failed with status {}: {}", status, error_text);
154
+
return Err(AppError::Internal(format!("AIP registration failed with status {}: {}", status, error_text)));
155
+
}
156
+
157
+
tracing::debug!("Parsing AIP response JSON...");
158
+
159
+
// Get the response body as text first to debug what we're receiving
160
+
let response_body = aip_response
161
+
.text()
162
+
.await
163
+
.map_err(|e| AppError::Internal(format!("Failed to get response body: {}", e)))?;
164
+
165
+
tracing::debug!("AIP response body: {}", response_body);
166
+
167
+
// Try to parse the JSON from the text
168
+
let aip_client: AipClientRegistrationResponse = serde_json::from_str(&response_body)
169
+
.map_err(|e| {
170
+
tracing::error!("Failed to parse AIP response JSON: {}", e);
171
+
tracing::error!("Raw response was: {}", response_body);
172
+
AppError::Internal(format!("Failed to parse AIP response: {}", e))
173
+
})?;
174
+
175
+
tracing::debug!("Successfully parsed AIP response, client_id: {}", aip_client.client_id);
176
+
177
+
// Store the client info in our database
178
+
tracing::debug!("Storing OAuth client in database...");
179
+
let oauth_client = state.database
180
+
.create_oauth_client(
181
+
&request.slice_uri,
182
+
&aip_client.client_id,
183
+
aip_client.registration_access_token.as_deref(),
184
+
&user_did,
185
+
)
186
+
.await
187
+
.map_err(|e| {
188
+
tracing::error!("Failed to store OAuth client in database: {}", e);
189
+
AppError::Internal(format!("Failed to store OAuth client: {}", e))
190
+
})?;
191
+
192
+
tracing::debug!("Successfully stored OAuth client in database");
193
+
194
+
// Return the full client details from AIP
195
+
let response = OAuthClientDetails {
196
+
client_id: aip_client.client_id,
197
+
client_secret: aip_client.client_secret,
198
+
client_name: aip_client.client_name,
199
+
redirect_uris: aip_client.redirect_uris,
200
+
grant_types: aip_client.grant_types,
201
+
response_types: aip_client.response_types,
202
+
scope: aip_client.scope,
203
+
client_uri: aip_client.client_uri,
204
+
logo_uri: aip_client.logo_uri,
205
+
tos_uri: aip_client.tos_uri,
206
+
policy_uri: aip_client.policy_uri,
207
+
created_at: oauth_client.created_at,
208
+
created_by_did: oauth_client.created_by_did,
209
+
};
210
+
211
+
Ok(Json(response))
212
+
}
213
+
214
+
pub async fn get_oauth_clients(
215
+
State(state): State<AppState>,
216
+
headers: HeaderMap,
217
+
Query(params): Query<GetOAuthClientsQuery>,
218
+
) -> Result<Json<ListOAuthClientsResponse>, AppError> {
219
+
tracing::debug!("get_oauth_clients called with slice parameter: {}", params.slice);
220
+
221
+
// Log all headers for debugging
222
+
tracing::debug!("Request headers: {:?}", headers);
223
+
224
+
// Extract and verify authentication
225
+
let token = auth::extract_bearer_token(&headers)
226
+
.map_err(|e| {
227
+
tracing::error!("Failed to extract bearer token: {:?}", e);
228
+
AppError::BadRequest("Missing or invalid Authorization header".to_string())
229
+
})?;
230
+
231
+
tracing::debug!("Extracted bearer token (first 20 chars): {}...",
232
+
if token.len() > 20 { &token[..20] } else { &token });
233
+
234
+
auth::verify_oauth_token(&token, &state.config.auth_base_url).await
235
+
.map_err(|e| {
236
+
tracing::error!("OAuth token verification failed: {:?}", e);
237
+
AppError::BadRequest("Invalid or expired access token".to_string())
238
+
})?;
239
+
240
+
tracing::debug!("OAuth token verification successful");
241
+
242
+
// Get clients from our database
243
+
let clients = state.database
244
+
.get_oauth_clients_for_slice(¶ms.slice)
245
+
.await
246
+
.map_err(|e| AppError::Internal(format!("Failed to fetch OAuth clients: {}", e)))?;
247
+
248
+
tracing::debug!("Found {} OAuth clients in database for slice: {}", clients.len(), params.slice);
249
+
250
+
if clients.is_empty() {
251
+
return Ok(Json(ListOAuthClientsResponse { clients: vec![] }));
252
+
}
253
+
254
+
// Fetch detailed info from AIP for each client
255
+
let aip_base_url = &state.config.auth_base_url;
256
+
let client = Client::new();
257
+
let mut client_details = Vec::new();
258
+
259
+
for oauth_client in clients {
260
+
// Fetch client details from AIP
261
+
let aip_url = format!("{}/oauth/clients/{}", aip_base_url, oauth_client.client_id);
262
+
tracing::debug!("Fetching client details from AIP: {}", aip_url);
263
+
264
+
// Use registration access token if available for authentication
265
+
let mut request_builder = client.get(&aip_url);
266
+
if let Some(token) = &oauth_client.registration_access_token {
267
+
request_builder = request_builder.bearer_auth(token);
268
+
tracing::debug!("Using registration access token for authentication");
269
+
} else {
270
+
tracing::debug!("No registration access token available");
271
+
}
272
+
273
+
let aip_response = request_builder.send().await;
274
+
275
+
match aip_response {
276
+
Ok(response) => {
277
+
let status = response.status();
278
+
tracing::debug!("AIP response status for {}: {}", oauth_client.client_id, status);
279
+
280
+
if status.is_success() {
281
+
// Get the response body as text first to log it
282
+
match response.text().await {
283
+
Ok(response_text) => {
284
+
tracing::debug!("AIP response body for {}: {}", oauth_client.client_id, response_text);
285
+
286
+
// Try to parse the JSON
287
+
match serde_json::from_str::<AipClientRegistrationResponse>(&response_text) {
288
+
Ok(aip_client) => {
289
+
tracing::debug!("Successfully parsed AIP client details for {}", oauth_client.client_id);
290
+
client_details.push(OAuthClientDetails {
291
+
client_id: aip_client.client_id,
292
+
client_secret: aip_client.client_secret,
293
+
client_name: aip_client.client_name,
294
+
redirect_uris: aip_client.redirect_uris,
295
+
grant_types: aip_client.grant_types,
296
+
response_types: aip_client.response_types,
297
+
scope: aip_client.scope,
298
+
client_uri: aip_client.client_uri,
299
+
logo_uri: aip_client.logo_uri,
300
+
tos_uri: aip_client.tos_uri,
301
+
policy_uri: aip_client.policy_uri,
302
+
created_at: oauth_client.created_at,
303
+
created_by_did: oauth_client.created_by_did,
304
+
});
305
+
}
306
+
Err(parse_error) => {
307
+
tracing::error!("Failed to parse AIP client JSON for {}: {}", oauth_client.client_id, parse_error);
308
+
}
309
+
}
310
+
}
311
+
Err(text_error) => {
312
+
tracing::error!("Failed to get AIP response text for {}: {}", oauth_client.client_id, text_error);
313
+
}
314
+
}
315
+
} else {
316
+
// Handle non-success status codes
317
+
match response.text().await {
318
+
Ok(error_text) => {
319
+
tracing::error!("AIP client fetch failed with status {} for {}: {}", status, oauth_client.client_id, error_text);
320
+
}
321
+
Err(_) => {
322
+
tracing::error!("AIP client fetch failed with status {} for {}", status, oauth_client.client_id);
323
+
}
324
+
}
325
+
}
326
+
}
327
+
Err(e) => {
328
+
tracing::error!("AIP client fetch error for {}: {}", oauth_client.client_id, e);
329
+
// If we can't fetch from AIP, create a minimal response
330
+
client_details.push(OAuthClientDetails {
331
+
client_id: oauth_client.client_id.clone(),
332
+
client_secret: None,
333
+
client_name: "Unknown".to_string(),
334
+
redirect_uris: vec![],
335
+
grant_types: vec!["authorization_code".to_string()],
336
+
response_types: vec!["code".to_string()],
337
+
scope: None,
338
+
client_uri: None,
339
+
logo_uri: None,
340
+
tos_uri: None,
341
+
policy_uri: None,
342
+
created_at: oauth_client.created_at,
343
+
created_by_did: oauth_client.created_by_did,
344
+
});
345
+
}
346
+
}
347
+
}
348
+
349
+
Ok(Json(ListOAuthClientsResponse { clients: client_details }))
350
+
}
351
+
352
+
pub async fn update_oauth_client(
353
+
State(state): State<AppState>,
354
+
headers: HeaderMap,
355
+
ExtractJson(request): ExtractJson<UpdateOAuthClientRequest>,
356
+
) -> Result<Json<OAuthClientDetails>, AppError> {
357
+
let client_id = request.client_id.clone();
358
+
359
+
// Extract and verify authentication
360
+
let token = auth::extract_bearer_token(&headers)
361
+
.map_err(|_| AppError::BadRequest("Missing or invalid Authorization header".to_string()))?;
362
+
auth::verify_oauth_token(&token, &state.config.auth_base_url).await
363
+
.map_err(|_| AppError::BadRequest("Invalid or expired access token".to_string()))?;
364
+
365
+
// Get the client from our database to get the registration access token
366
+
let oauth_client = state.database
367
+
.get_oauth_client_by_id(&client_id)
368
+
.await
369
+
.map_err(|e| AppError::Internal(format!("Failed to fetch OAuth client: {}", e)))?
370
+
.ok_or_else(|| AppError::NotFound("OAuth client not found".to_string()))?;
371
+
372
+
let registration_token = oauth_client.registration_access_token
373
+
.ok_or_else(|| AppError::Internal("Client missing registration access token".to_string()))?;
374
+
375
+
// Build AIP update request
376
+
let aip_request = AipClientRegistrationRequest {
377
+
client_name: request.client_name.unwrap_or_default(),
378
+
redirect_uris: request.redirect_uris.unwrap_or_default(),
379
+
grant_types: None, // Keep existing
380
+
response_types: None, // Keep existing
381
+
scope: request.scope,
382
+
client_uri: request.client_uri,
383
+
logo_uri: request.logo_uri,
384
+
tos_uri: request.tos_uri,
385
+
policy_uri: request.policy_uri,
386
+
};
387
+
388
+
let aip_base_url = &state.config.auth_base_url;
389
+
let client = Client::new();
390
+
let update_url = format!("{}/oauth/clients/{}", aip_base_url, client_id);
391
+
392
+
tracing::debug!("Updating OAuth client at: {}", update_url);
393
+
tracing::debug!("Sending AIP update request: {:?}", aip_request);
394
+
395
+
let aip_response = client
396
+
.put(&update_url)
397
+
.bearer_auth(®istration_token)
398
+
.json(&aip_request)
399
+
.send()
400
+
.await
401
+
.map_err(|e| AppError::Internal(format!("Failed to update client with AIP: {}", e)))?;
402
+
403
+
let status = aip_response.status();
404
+
tracing::debug!("AIP update response status: {}", status);
405
+
406
+
if !status.is_success() {
407
+
let error_text = aip_response.text().await.unwrap_or_else(|_| "Unknown error".to_string());
408
+
tracing::error!("AIP update failed with status {}: {}", status, error_text);
409
+
return Err(AppError::Internal(format!("AIP update failed with status {}: {}", status, error_text)));
410
+
}
411
+
412
+
// Parse the response
413
+
let response_body = aip_response
414
+
.text()
415
+
.await
416
+
.map_err(|e| AppError::Internal(format!("Failed to get response body: {}", e)))?;
417
+
418
+
tracing::debug!("AIP update response body: {}", response_body);
419
+
420
+
let aip_client: AipClientRegistrationResponse = serde_json::from_str(&response_body)
421
+
.map_err(|e| {
422
+
tracing::error!("Failed to parse AIP response JSON: {}", e);
423
+
AppError::Internal(format!("Failed to parse AIP response: {}", e))
424
+
})?;
425
+
426
+
// Return the updated client details
427
+
let response = OAuthClientDetails {
428
+
client_id: aip_client.client_id,
429
+
client_secret: aip_client.client_secret,
430
+
client_name: aip_client.client_name,
431
+
redirect_uris: aip_client.redirect_uris,
432
+
grant_types: aip_client.grant_types,
433
+
response_types: aip_client.response_types,
434
+
scope: aip_client.scope,
435
+
client_uri: aip_client.client_uri,
436
+
logo_uri: aip_client.logo_uri,
437
+
tos_uri: aip_client.tos_uri,
438
+
policy_uri: aip_client.policy_uri,
439
+
created_at: oauth_client.created_at,
440
+
created_by_did: oauth_client.created_by_did,
441
+
};
442
+
443
+
Ok(Json(response))
444
+
}
445
+
446
+
pub async fn delete_oauth_client(
447
+
State(state): State<AppState>,
448
+
headers: HeaderMap,
449
+
ExtractJson(request): ExtractJson<DeleteOAuthClientRequest>,
450
+
) -> Result<Json<DeleteOAuthClientResponse>, AppError> {
451
+
let client_id = request.client_id;
452
+
// Extract and verify authentication
453
+
let token = auth::extract_bearer_token(&headers)
454
+
.map_err(|_| AppError::BadRequest("Missing or invalid Authorization header".to_string()))?;
455
+
auth::verify_oauth_token(&token, &state.config.auth_base_url).await
456
+
.map_err(|_| AppError::BadRequest("Invalid or expired access token".to_string()))?;
457
+
458
+
// Get the client from our database first
459
+
let oauth_client = state.database
460
+
.get_oauth_client_by_id(&client_id)
461
+
.await
462
+
.map_err(|e| AppError::Internal(format!("Failed to fetch OAuth client: {}", e)))?
463
+
.ok_or_else(|| AppError::NotFound("OAuth client not found".to_string()))?;
464
+
465
+
// Delete from AIP if we have a registration access token
466
+
if let Some(registration_token) = &oauth_client.registration_access_token {
467
+
let aip_base_url = &state.config.auth_base_url;
468
+
469
+
let client = Client::new();
470
+
let _aip_response = client
471
+
.delete(&format!("{}/oauth/clients/{}", aip_base_url, client_id))
472
+
.bearer_auth(registration_token)
473
+
.send()
474
+
.await;
475
+
// We continue even if AIP deletion fails, as we want to clean up our database
476
+
}
477
+
478
+
// Delete from our database
479
+
state.database
480
+
.delete_oauth_client(&client_id)
481
+
.await
482
+
.map_err(|e| AppError::Internal(format!("Failed to delete OAuth client: {}", e)))?;
483
+
484
+
Ok(Json(DeleteOAuthClientResponse {
485
+
success: true,
486
+
message: format!("OAuth client {} deleted successfully", client_id),
487
+
}))
488
+
}
+18
api/src/main.rs
+18
api/src/main.rs
···
8
8
mod handler_jetstream_status;
9
9
mod handler_jobs;
10
10
mod handler_logs;
11
+
mod handler_oauth_clients;
11
12
mod handler_openapi_spec;
12
13
mod handler_stats;
13
14
mod handler_sync;
···
358
359
.route(
359
360
"/xrpc/social.slices.slice.getActors",
360
361
post(handler_get_actors::get_actors),
362
+
)
363
+
// OAuth client management endpoints
364
+
.route(
365
+
"/xrpc/social.slices.slice.createOAuthClient",
366
+
post(handler_oauth_clients::create_oauth_client),
367
+
)
368
+
.route(
369
+
"/xrpc/social.slices.slice.getOAuthClients",
370
+
get(handler_oauth_clients::get_oauth_clients),
371
+
)
372
+
.route(
373
+
"/xrpc/social.slices.slice.updateOAuthClient",
374
+
post(handler_oauth_clients::update_oauth_client),
375
+
)
376
+
.route(
377
+
"/xrpc/social.slices.slice.deleteOAuthClient",
378
+
post(handler_oauth_clients::delete_oauth_client),
361
379
)
362
380
// Dynamic collection-specific XRPC endpoints (wildcard routes must come last)
363
381
.route(
+70
api/src/models.rs
+70
api/src/models.rs
···
133
133
pub cursor: Option<String>,
134
134
pub message: Option<String>,
135
135
}
136
+
137
+
#[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow)]
138
+
#[serde(rename_all = "camelCase")]
139
+
pub struct OAuthClient {
140
+
pub id: i32,
141
+
pub slice_uri: String,
142
+
pub client_id: String,
143
+
pub registration_access_token: Option<String>,
144
+
pub created_at: DateTime<Utc>,
145
+
pub created_by_did: String,
146
+
}
147
+
148
+
#[derive(Debug, Serialize, Deserialize)]
149
+
#[serde(rename_all = "camelCase")]
150
+
pub struct CreateOAuthClientRequest {
151
+
pub slice_uri: String,
152
+
pub client_name: String,
153
+
pub redirect_uris: Vec<String>,
154
+
pub grant_types: Option<Vec<String>>,
155
+
pub response_types: Option<Vec<String>>,
156
+
pub scope: Option<String>,
157
+
pub client_uri: Option<String>,
158
+
pub logo_uri: Option<String>,
159
+
pub tos_uri: Option<String>,
160
+
pub policy_uri: Option<String>,
161
+
}
162
+
163
+
#[derive(Debug, Serialize, Deserialize)]
164
+
#[serde(rename_all = "camelCase")]
165
+
pub struct OAuthClientDetails {
166
+
pub client_id: String,
167
+
pub client_secret: Option<String>,
168
+
pub client_name: String,
169
+
pub redirect_uris: Vec<String>,
170
+
pub grant_types: Vec<String>,
171
+
pub response_types: Vec<String>,
172
+
pub scope: Option<String>,
173
+
pub client_uri: Option<String>,
174
+
pub logo_uri: Option<String>,
175
+
pub tos_uri: Option<String>,
176
+
pub policy_uri: Option<String>,
177
+
pub created_at: DateTime<Utc>,
178
+
pub created_by_did: String,
179
+
}
180
+
181
+
#[derive(Debug, Serialize, Deserialize)]
182
+
#[serde(rename_all = "camelCase")]
183
+
pub struct ListOAuthClientsResponse {
184
+
pub clients: Vec<OAuthClientDetails>,
185
+
}
186
+
187
+
#[derive(Debug, Serialize, Deserialize)]
188
+
#[serde(rename_all = "camelCase")]
189
+
pub struct UpdateOAuthClientRequest {
190
+
pub client_id: String,
191
+
pub client_name: Option<String>,
192
+
pub redirect_uris: Option<Vec<String>>,
193
+
pub scope: Option<String>,
194
+
pub client_uri: Option<String>,
195
+
pub logo_uri: Option<String>,
196
+
pub tos_uri: Option<String>,
197
+
pub policy_uri: Option<String>,
198
+
}
199
+
200
+
#[derive(Debug, Serialize, Deserialize)]
201
+
#[serde(rename_all = "camelCase")]
202
+
pub struct DeleteOAuthClientResponse {
203
+
pub success: bool,
204
+
pub message: String,
205
+
}
+3
-1
docker-compose.yml
+3
-1
docker-compose.yml
···
29
29
- default
30
30
31
31
aip:
32
-
image: ghcr.io/bigmoves/aip/aip-sqlite:main-7b2c1ee
32
+
image: ghcr.io/bigmoves/aip/aip-sqlite:main-5bbc55c
33
33
environment:
34
34
EXTERNAL_BASE: "${AIP_EXTERNAL_BASE:-http://localhost:8081}"
35
35
HTTP_PORT: "8081"
···
40
40
ADMIN_DIDS: "did:plc:bcgltzqazw5tb6k2g3ttenbj"
41
41
DPOP_NONCE_SEED: "local-dev-nonce-seed"
42
42
RUST_LOG: "aip=trace,sqlx=debug,tower_http=debug,atproto_identity=debug,atproto_oauth=debug"
43
+
ATPROTO_OAUTH_SIGNING_KEYS: "z42tzC26Phdvnzmm7mVgLVgH6cDy3i1A2UcH8m6XbgKVJ4zk"
44
+
OAUTH_SIGNING_KEYS: "z42tzC26Phdvnzmm7mVgLVgH6cDy3i1A2UcH8m6XbgKVJ4zk"
43
45
ports:
44
46
- "8081:8081"
45
47
volumes:
+65
frontend/deno.lock
+65
frontend/deno.lock
···
450
450
"integrity": "sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A=="
451
451
}
452
452
},
453
+
"redirects": {
454
+
"https://esm.sh/@atproto/crypto": "https://esm.sh/@atproto/crypto@0.4.4",
455
+
"https://esm.sh/@noble/curves@^1.7.0/p256?target=denonext": "https://esm.sh/@noble/curves@1.9.7/p256?target=denonext",
456
+
"https://esm.sh/@noble/curves@^1.7.0/secp256k1?target=denonext": "https://esm.sh/@noble/curves@1.9.7/secp256k1?target=denonext",
457
+
"https://esm.sh/@noble/hashes@^1.6.1/sha256?target=denonext": "https://esm.sh/@noble/hashes@1.8.0/sha256?target=denonext",
458
+
"https://esm.sh/@noble/hashes@^1.6.1/utils?target=denonext": "https://esm.sh/@noble/hashes@1.8.0/utils?target=denonext",
459
+
"https://esm.sh/multiformats@^9.4.2/basics?target=denonext": "https://esm.sh/multiformats@9.9.0/basics?target=denonext"
460
+
},
461
+
"remote": {
462
+
"https://deno.land/std@0.208.0/encoding/_util.ts": "f368920189c4fe6592ab2e93bd7ded8f3065b84f95cd3e036a4a10a75649dcba",
463
+
"https://deno.land/std@0.208.0/encoding/base64.ts": "81c0ecff5ccb402def58ca03d8bd245bd01da15a077d3362b568e991aa10f4d9",
464
+
"https://deno.land/std@0.208.0/encoding/base64url.ts": "b15a4c8c988362536334a31dbb75a2429346f8c0f9e2662f961567c5dd2f58e1",
465
+
"https://esm.sh/@atproto/crypto@0.4.4": "1baac5a764fe567bb3d4e9e96c24a48d3f53f50c27cb49d379aa438d1025bed1",
466
+
"https://esm.sh/@atproto/crypto@0.4.4/denonext/crypto.mjs": "aabf92bb5f30066fe9c6f8c57fe3bd34ef36fd8a5c0c241232898d9cffb505ca",
467
+
"https://esm.sh/@noble/curves@1.9.7/denonext/_shortw_utils.mjs": "d62ddfbe9cb2215269d9b56447c5aecc342999419cb9f6bd66e991ee7bc28d35",
468
+
"https://esm.sh/@noble/curves@1.9.7/denonext/abstract/curve.mjs": "ca7c724c2cf4e0cd5f9d0c3eed4b3081cdb8c017e12638a2722fd76922ff9c44",
469
+
"https://esm.sh/@noble/curves@1.9.7/denonext/abstract/hash-to-curve.mjs": "bac93e692bd50bb338d104a59b841acb2a6246d131122914b9e78e029abb0d63",
470
+
"https://esm.sh/@noble/curves@1.9.7/denonext/abstract/modular.mjs": "cfa09121cbd673d611b9f696ed8119c10df4fa0fbaecbca19233909f7c6f504d",
471
+
"https://esm.sh/@noble/curves@1.9.7/denonext/abstract/weierstrass.mjs": "28c5cbd946e5dfed647201f66148c1c383c26185c35434872c2b9b512c7e3957",
472
+
"https://esm.sh/@noble/curves@1.9.7/denonext/nist.mjs": "235e7e9002fda632292709284783e3265444c90b19fedb1a9a48d121d979bd08",
473
+
"https://esm.sh/@noble/curves@1.9.7/denonext/p256.mjs": "7840b9d0fd088d3a4e9727883b57a8db59f4815662b9b589c19555fde2580eea",
474
+
"https://esm.sh/@noble/curves@1.9.7/denonext/secp256k1.mjs": "942283ed893724ae84fb8d3764f79695ca0883d7575afd718c889a09d446b173",
475
+
"https://esm.sh/@noble/curves@1.9.7/denonext/utils.mjs": "78800caea02fb59f99fe68775bcde329357138874dcdb4e6d2f56e76b4eeeaf0",
476
+
"https://esm.sh/@noble/curves@1.9.7/p256?target=denonext": "4719247c47798f580549d2f852f28adddfa599056bb7b0166e58ada5bf038aec",
477
+
"https://esm.sh/@noble/curves@1.9.7/secp256k1?target=denonext": "3e69e23e4c33f1c76f0066344fc77682c059ec53959aa4c03b5d48b4b63ccd2b",
478
+
"https://esm.sh/@noble/hashes@1.8.0/denonext/_md.mjs": "e30debbd8e964d8bd4ae9c807c75601e3ef7ec73eaed005f703709f64a4b2257",
479
+
"https://esm.sh/@noble/hashes@1.8.0/denonext/crypto.mjs": "cabc4468470c6f6d15891c4a6037aebed19289e1a12eb9905c5579c26767a721",
480
+
"https://esm.sh/@noble/hashes@1.8.0/denonext/hmac.mjs": "f6e5f679509f87084ebfc0e0334a8ce28788cf94868a61807420618dfc392325",
481
+
"https://esm.sh/@noble/hashes@1.8.0/denonext/sha2.mjs": "1bf8c30d97f7fe1546e2fed6777a0da85e78d6e961eeae0e0b4437dabf016b7a",
482
+
"https://esm.sh/@noble/hashes@1.8.0/denonext/sha256.mjs": "43f16ee7a29cabde4a66d24226da441240f587dc34c568661a1b3f392fda177a",
483
+
"https://esm.sh/@noble/hashes@1.8.0/denonext/utils.mjs": "18b15a49e98c9e136e4bfcdce69b9461630b7dc3389ba932f535ad4a47221b08",
484
+
"https://esm.sh/@noble/hashes@1.8.0/sha256?target=denonext": "1804d21efec0beea92617d60ab84cd45d16d58bc709830df4da1be95a8fc69fc",
485
+
"https://esm.sh/@noble/hashes@1.8.0/utils?target=denonext": "18820d033cf3fe7481337101dd51a7bb50bd4a0d9ee8fe97fe9b9e5af8bfd5ee",
486
+
"https://esm.sh/multiformats@9.9.0/basics?target=denonext": "97fa53d99c2a3aa56367e5e2a34ab3d1b3dc496e4a4fe490460d2955de707361",
487
+
"https://esm.sh/multiformats@9.9.0/denonext/bases/base10.mjs": "07ac037675bfbbf0621e7f8fd3cfeb242d1ab0955d7e965f4f3a2daa1c369b85",
488
+
"https://esm.sh/multiformats@9.9.0/denonext/bases/base16.mjs": "7f0c9b5860c52b54170cbc8b058fe46eee1b81f52d0908055d38d2a63ec8b721",
489
+
"https://esm.sh/multiformats@9.9.0/denonext/bases/base2.mjs": "46527ded4d9b868600b2fd8902398a31226655790bd3c5f61ffa0bd8737b0698",
490
+
"https://esm.sh/multiformats@9.9.0/denonext/bases/base256emoji.mjs": "7c16b9576b295024837fe5d192f0b854951ac3a7c0be1be8a3c91d5f62505066",
491
+
"https://esm.sh/multiformats@9.9.0/denonext/bases/base32.mjs": "2c42d149c299e8b8934e51ccb284b01b113d3fe432177a55b7a781d10cfbe5b2",
492
+
"https://esm.sh/multiformats@9.9.0/denonext/bases/base36.mjs": "52dfe773e2d2650ed87c7c353e909a0d710ed83cb01eb553858eae4879a6664d",
493
+
"https://esm.sh/multiformats@9.9.0/denonext/bases/base58.mjs": "d45f93c89f6f8a05c7ddc132c99a2bc866d1de2e8475747b6722f6322482feab",
494
+
"https://esm.sh/multiformats@9.9.0/denonext/bases/base64.mjs": "1365d8ab96a8998be1663e7277eebc58d3207f785c9ab53533966626f153461b",
495
+
"https://esm.sh/multiformats@9.9.0/denonext/bases/base8.mjs": "9336d2259eb06c31fd0d1e7e5ae36e0826f39b2f73639849a403933348c7526c",
496
+
"https://esm.sh/multiformats@9.9.0/denonext/bases/identity.mjs": "28acc5f7d4dfe7d5647e1c8d250540a50ba6397ff4f375bc41dc37fc5d5de510",
497
+
"https://esm.sh/multiformats@9.9.0/denonext/basics.mjs": "edd1f5f7171a026940535586a6682c88f036b972a0158d10db2a5936239182ec",
498
+
"https://esm.sh/multiformats@9.9.0/denonext/cid.mjs": "1945384d570468b0bb5bd0f394184835038d5778d701b50b6b7eb7273748d288",
499
+
"https://esm.sh/multiformats@9.9.0/denonext/codecs/json.mjs": "a31ef601f2480daa1ed34393e91f937cef522517ca7d934b86939f8d761fe077",
500
+
"https://esm.sh/multiformats@9.9.0/denonext/codecs/raw.mjs": "6d6b44e3bea526dd9930d61631709f45e9f10bf2e190dc19168e9b0297fb30dd",
501
+
"https://esm.sh/multiformats@9.9.0/denonext/esm/src/bases/base.mjs": "f0057681c3f918b72d77de38d89d1f532ae4fc78b86f35db7f617c81e7ac504f",
502
+
"https://esm.sh/multiformats@9.9.0/denonext/esm/src/bytes.mjs": "d2fa273fd87212f525dcd3863af8a3d2ca1e3ee42aca3eaee5ddd4ad6e43ba04",
503
+
"https://esm.sh/multiformats@9.9.0/denonext/esm/src/varint.mjs": "5ea3af2ab0109f1e9f4f56fe35883f593f6b047899ada90095a5ed518d635899",
504
+
"https://esm.sh/multiformats@9.9.0/denonext/hashes/digest.mjs": "fc07873514b182ae0897159ebdcad3dd3ceced04740aa76caf4b2e796ff6ca25",
505
+
"https://esm.sh/multiformats@9.9.0/denonext/hashes/hasher.mjs": "44bfd064461927c2e8f161db65053d711e59e3df4b55b76bb3f9bf4c316492ad",
506
+
"https://esm.sh/multiformats@9.9.0/denonext/hashes/identity.mjs": "43eaf0a3160c8344e2f70387d0a124f3f21363d82d3e246a7a7df4053c6199e4",
507
+
"https://esm.sh/multiformats@9.9.0/denonext/hashes/sha2.mjs": "a0fd2e20d8753f6ca30db815bb7f20a57e2a23dd691384b11d07b5a52dbec74f",
508
+
"https://esm.sh/multiformats@9.9.0/denonext/multiformats.mjs": "7449f492d80b1dcffcbbfb5599707e5f9c0e5a54694532f330b0f64defe27809",
509
+
"https://esm.sh/uint8arrays@3.0.0/denonext/compare.mjs": "2b8ed67b92836546e504b87733eacd3f0569050ea645920d3f2503e8e335cd01",
510
+
"https://esm.sh/uint8arrays@3.0.0/denonext/concat.mjs": "ce3cfbcdd3cc6d1d9fa75f13963c7398a377d8a1dbd6302820f2058f42545fc7",
511
+
"https://esm.sh/uint8arrays@3.0.0/denonext/equals.mjs": "2c9a9504f97abc172b755fb5069cb9b69aac2e177ad4bc2cf9afdd6a4a322694",
512
+
"https://esm.sh/uint8arrays@3.0.0/denonext/esm/src/util/bases.mjs": "03de06f47b410a6ed09da73d6e58696ca34ac4e51bce4a0bcfac5cf88c23b8da",
513
+
"https://esm.sh/uint8arrays@3.0.0/denonext/from-string.mjs": "6df06b3ed43db82fa33500d4ab01a97ae4ecef234f76e06c1774b915d913bbe3",
514
+
"https://esm.sh/uint8arrays@3.0.0/denonext/to-string.mjs": "bafce61afad1706118c9c6bd08d6d2f53dada2c0892b8fe14da8124e5951390c",
515
+
"https://esm.sh/uint8arrays@3.0.0/denonext/uint8arrays.mjs": "2627bbf05b4b496ffe067a4f090dd91d4aa7bc8c815d36f1e543531fb0d9342f",
516
+
"https://esm.sh/uint8arrays@3.0.0/denonext/xor.mjs": "cc93f46198dca299adc26ae5344768914923bbc88e78eefd28699d0ec28c8e71"
517
+
},
453
518
"workspace": {
454
519
"dependencies": [
455
520
"jsr:@slices/oauth@~0.3.2",
+90
-1
frontend/src/client.ts
+90
-1
frontend/src/client.ts
···
1
1
// Generated TypeScript client for AT Protocol records
2
-
// Generated at: 2025-09-04 05:32:23 UTC
2
+
// Generated at: 2025-09-07 23:17:29 UTC
3
3
// Lexicons: 6
4
4
5
5
/**
···
304
304
params?: Omit<SliceRecordsParams<TSortField>, "slice">
305
305
): Promise<GetRecordsResponse<T>>;
306
306
getRecord(params: GetRecordParams): Promise<RecordResponse<T>>;
307
+
}
308
+
309
+
export interface CreateOAuthClientRequest {
310
+
clientName: string;
311
+
redirectUris: string[];
312
+
grantTypes?: string[];
313
+
responseTypes?: string[];
314
+
scope?: string;
315
+
clientUri?: string;
316
+
logoUri?: string;
317
+
tosUri?: string;
318
+
policyUri?: string;
319
+
}
320
+
321
+
export interface OAuthClientDetails {
322
+
clientId: string;
323
+
clientSecret?: string;
324
+
clientName: string;
325
+
redirectUris: string[];
326
+
grantTypes: string[];
327
+
responseTypes: string[];
328
+
scope?: string;
329
+
clientUri?: string;
330
+
logoUri?: string;
331
+
tosUri?: string;
332
+
policyUri?: string;
333
+
createdAt: string;
334
+
createdByDid: string;
335
+
}
336
+
337
+
export interface ListOAuthClientsResponse {
338
+
clients: OAuthClientDetails[];
339
+
}
340
+
341
+
export interface UpdateOAuthClientRequest {
342
+
clientId: string;
343
+
clientName?: string;
344
+
redirectUris?: string[];
345
+
scope?: string;
346
+
clientUri?: string;
347
+
logoUri?: string;
348
+
tosUri?: string;
349
+
policyUri?: string;
350
+
}
351
+
352
+
export interface DeleteOAuthClientResponse {
353
+
success: boolean;
354
+
message: string;
307
355
}
308
356
309
357
export interface AppBskyActorProfile {
···
982
1030
"social.slices.slice.syncUserCollections",
983
1031
"POST",
984
1032
requestParams
1033
+
);
1034
+
}
1035
+
1036
+
async createOAuthClient(
1037
+
params: CreateOAuthClientRequest
1038
+
): Promise<OAuthClientDetails> {
1039
+
const requestParams = { ...params, sliceUri: this.sliceUri };
1040
+
return await this.makeRequest<OAuthClientDetails>(
1041
+
"social.slices.slice.createOAuthClient",
1042
+
"POST",
1043
+
requestParams
1044
+
);
1045
+
}
1046
+
1047
+
async getOAuthClients(): Promise<ListOAuthClientsResponse> {
1048
+
const requestParams = { slice: this.sliceUri };
1049
+
return await this.makeRequest<ListOAuthClientsResponse>(
1050
+
"social.slices.slice.getOAuthClients",
1051
+
"GET",
1052
+
requestParams
1053
+
);
1054
+
}
1055
+
1056
+
async updateOAuthClient(
1057
+
params: UpdateOAuthClientRequest
1058
+
): Promise<OAuthClientDetails> {
1059
+
const requestParams = { ...params, sliceUri: this.sliceUri };
1060
+
return await this.makeRequest<OAuthClientDetails>(
1061
+
"social.slices.slice.updateOAuthClient",
1062
+
"POST",
1063
+
requestParams
1064
+
);
1065
+
}
1066
+
1067
+
async deleteOAuthClient(
1068
+
clientId: string
1069
+
): Promise<DeleteOAuthClientResponse> {
1070
+
return await this.makeRequest<DeleteOAuthClientResponse>(
1071
+
"social.slices.slice.deleteOAuthClient",
1072
+
"POST",
1073
+
{ clientId }
985
1074
);
986
1075
}
987
1076
}
+370
frontend/src/components/OAuthClientModal.tsx
+370
frontend/src/components/OAuthClientModal.tsx
···
1
+
import { OAuthClientDetails } from "../client.ts";
2
+
3
+
interface OAuthClientModalProps {
4
+
sliceId: string;
5
+
sliceUri: string;
6
+
mode: "new" | "view";
7
+
clientData?: OAuthClientDetails;
8
+
}
9
+
10
+
export function OAuthClientModal({
11
+
sliceId,
12
+
sliceUri,
13
+
mode,
14
+
clientData,
15
+
}: OAuthClientModalProps) {
16
+
if (mode === "view" && clientData) {
17
+
return (
18
+
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
19
+
<div className="bg-white rounded-lg p-6 max-w-2xl w-full max-h-[90vh] overflow-y-auto">
20
+
<form
21
+
hx-post={`/api/slices/${sliceId}/oauth/${encodeURIComponent(clientData.clientId)}/update`}
22
+
hx-target="#modal-container"
23
+
hx-swap="outerHTML"
24
+
>
25
+
<div className="flex justify-between items-start mb-4">
26
+
<h2 className="text-2xl font-semibold">OAuth Client Details</h2>
27
+
<button
28
+
type="button"
29
+
_="on click set #modal-container's innerHTML to ''"
30
+
className="text-gray-400 hover:text-gray-600"
31
+
>
32
+
✕
33
+
</button>
34
+
</div>
35
+
36
+
<div className="space-y-4">
37
+
{/* Client ID - Read-only */}
38
+
<div>
39
+
<label className="block text-sm font-medium text-gray-700 mb-1">
40
+
Client ID
41
+
</label>
42
+
<div className="font-mono text-sm bg-gray-100 p-2 rounded border">
43
+
{clientData.clientId}
44
+
</div>
45
+
</div>
46
+
47
+
{/* Client Secret - Read-only, only shown once */}
48
+
{clientData.clientSecret && (
49
+
<div>
50
+
<label className="block text-sm font-medium text-gray-700 mb-1">
51
+
Client Secret
52
+
</label>
53
+
<div className="font-mono text-sm bg-yellow-50 border border-yellow-200 p-2 rounded">
54
+
<div className="text-yellow-800 text-xs mb-1">⚠️ Save this secret - it won't be shown again</div>
55
+
{clientData.clientSecret}
56
+
</div>
57
+
</div>
58
+
)}
59
+
60
+
{/* Client Name - Editable */}
61
+
<div>
62
+
<label
63
+
htmlFor="clientName"
64
+
className="block text-sm font-medium text-gray-700 mb-1"
65
+
>
66
+
Client Name <span className="text-red-500">*</span>
67
+
</label>
68
+
<input
69
+
type="text"
70
+
id="clientName"
71
+
name="clientName"
72
+
required
73
+
defaultValue={clientData.clientName}
74
+
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
75
+
/>
76
+
</div>
77
+
78
+
{/* Redirect URIs - Editable */}
79
+
<div>
80
+
<label
81
+
htmlFor="redirectUris"
82
+
className="block text-sm font-medium text-gray-700 mb-1"
83
+
>
84
+
Redirect URIs <span className="text-red-500">*</span>
85
+
</label>
86
+
<textarea
87
+
id="redirectUris"
88
+
name="redirectUris"
89
+
required
90
+
rows={3}
91
+
defaultValue={clientData.redirectUris.join('\n')}
92
+
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
93
+
/>
94
+
<p className="text-sm text-gray-500 mt-1">
95
+
Enter one redirect URI per line
96
+
</p>
97
+
</div>
98
+
99
+
{/* Scope - Editable */}
100
+
<div>
101
+
<label
102
+
htmlFor="scope"
103
+
className="block text-sm font-medium text-gray-700 mb-1"
104
+
>
105
+
Scope
106
+
</label>
107
+
<input
108
+
type="text"
109
+
id="scope"
110
+
name="scope"
111
+
defaultValue={clientData.scope || ''}
112
+
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
113
+
placeholder="atproto:atproto"
114
+
/>
115
+
</div>
116
+
117
+
{/* Client URI - Editable */}
118
+
<div>
119
+
<label
120
+
htmlFor="clientUri"
121
+
className="block text-sm font-medium text-gray-700 mb-1"
122
+
>
123
+
Client URI
124
+
</label>
125
+
<input
126
+
type="url"
127
+
id="clientUri"
128
+
name="clientUri"
129
+
defaultValue={clientData.clientUri || ''}
130
+
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
131
+
placeholder="https://example.com"
132
+
/>
133
+
</div>
134
+
135
+
{/* Logo URI - Editable */}
136
+
<div>
137
+
<label
138
+
htmlFor="logoUri"
139
+
className="block text-sm font-medium text-gray-700 mb-1"
140
+
>
141
+
Logo URI
142
+
</label>
143
+
<input
144
+
type="url"
145
+
id="logoUri"
146
+
name="logoUri"
147
+
defaultValue={clientData.logoUri || ''}
148
+
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
149
+
placeholder="https://example.com/logo.png"
150
+
/>
151
+
</div>
152
+
153
+
{/* Terms of Service URI - Editable */}
154
+
<div>
155
+
<label
156
+
htmlFor="tosUri"
157
+
className="block text-sm font-medium text-gray-700 mb-1"
158
+
>
159
+
Terms of Service URI
160
+
</label>
161
+
<input
162
+
type="url"
163
+
id="tosUri"
164
+
name="tosUri"
165
+
defaultValue={clientData.tosUri || ''}
166
+
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
167
+
placeholder="https://example.com/terms"
168
+
/>
169
+
</div>
170
+
171
+
{/* Privacy Policy URI - Editable */}
172
+
<div>
173
+
<label
174
+
htmlFor="policyUri"
175
+
className="block text-sm font-medium text-gray-700 mb-1"
176
+
>
177
+
Privacy Policy URI
178
+
</label>
179
+
<input
180
+
type="url"
181
+
id="policyUri"
182
+
name="policyUri"
183
+
defaultValue={clientData.policyUri || ''}
184
+
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
185
+
placeholder="https://example.com/privacy"
186
+
/>
187
+
</div>
188
+
189
+
<div className="flex justify-end gap-3 mt-6">
190
+
<button
191
+
type="button"
192
+
_="on click set #modal-container's innerHTML to ''"
193
+
className="px-4 py-2 bg-gray-200 text-gray-800 rounded hover:bg-gray-300"
194
+
>
195
+
Cancel
196
+
</button>
197
+
<button
198
+
type="submit"
199
+
className="px-4 py-2 bg-blue-600 text-white rounded hover:bg-blue-700"
200
+
>
201
+
Update Client
202
+
</button>
203
+
</div>
204
+
</div>
205
+
</form>
206
+
</div>
207
+
</div>
208
+
);
209
+
}
210
+
211
+
return (
212
+
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
213
+
<div className="bg-white rounded-lg p-6 max-w-2xl w-full max-h-[90vh] overflow-y-auto">
214
+
<form
215
+
hx-post={`/api/slices/${sliceId}/oauth/register`}
216
+
hx-target="#modal-container"
217
+
hx-swap="outerHTML"
218
+
>
219
+
<input type="hidden" name="sliceUri" value={sliceUri} />
220
+
221
+
<div className="flex justify-between items-start mb-4">
222
+
<h2 className="text-2xl font-semibold">Register OAuth Client</h2>
223
+
<button
224
+
type="button"
225
+
_="on click set #modal-container's innerHTML to ''"
226
+
className="text-gray-400 hover:text-gray-600"
227
+
>
228
+
✕
229
+
</button>
230
+
</div>
231
+
232
+
<div className="space-y-4">
233
+
<div>
234
+
<label
235
+
htmlFor="clientName"
236
+
className="block text-sm font-medium text-gray-700 mb-1"
237
+
>
238
+
Client Name <span className="text-red-500">*</span>
239
+
</label>
240
+
<input
241
+
type="text"
242
+
id="clientName"
243
+
name="clientName"
244
+
required
245
+
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
246
+
placeholder="My Application"
247
+
/>
248
+
</div>
249
+
250
+
<div>
251
+
<label
252
+
htmlFor="redirectUris"
253
+
className="block text-sm font-medium text-gray-700 mb-1"
254
+
>
255
+
Redirect URIs <span className="text-red-500">*</span>
256
+
</label>
257
+
<textarea
258
+
id="redirectUris"
259
+
name="redirectUris"
260
+
required
261
+
rows={3}
262
+
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
263
+
placeholder="https://example.com/callback https://localhost:3000/callback"
264
+
/>
265
+
<p className="text-sm text-gray-500 mt-1">
266
+
Enter one redirect URI per line
267
+
</p>
268
+
</div>
269
+
270
+
<div>
271
+
<label
272
+
htmlFor="scope"
273
+
className="block text-sm font-medium text-gray-700 mb-1"
274
+
>
275
+
Scope
276
+
</label>
277
+
<input
278
+
type="text"
279
+
id="scope"
280
+
name="scope"
281
+
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
282
+
placeholder="atproto:atproto"
283
+
/>
284
+
</div>
285
+
286
+
<div>
287
+
<label
288
+
htmlFor="clientUri"
289
+
className="block text-sm font-medium text-gray-700 mb-1"
290
+
>
291
+
Client URI
292
+
</label>
293
+
<input
294
+
type="url"
295
+
id="clientUri"
296
+
name="clientUri"
297
+
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
298
+
placeholder="https://example.com"
299
+
/>
300
+
</div>
301
+
302
+
<div>
303
+
<label
304
+
htmlFor="logoUri"
305
+
className="block text-sm font-medium text-gray-700 mb-1"
306
+
>
307
+
Logo URI
308
+
</label>
309
+
<input
310
+
type="url"
311
+
id="logoUri"
312
+
name="logoUri"
313
+
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
314
+
placeholder="https://example.com/logo.png"
315
+
/>
316
+
</div>
317
+
318
+
<div>
319
+
<label
320
+
htmlFor="tosUri"
321
+
className="block text-sm font-medium text-gray-700 mb-1"
322
+
>
323
+
Terms of Service URI
324
+
</label>
325
+
<input
326
+
type="url"
327
+
id="tosUri"
328
+
name="tosUri"
329
+
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
330
+
placeholder="https://example.com/terms"
331
+
/>
332
+
</div>
333
+
334
+
<div>
335
+
<label
336
+
htmlFor="policyUri"
337
+
className="block text-sm font-medium text-gray-700 mb-1"
338
+
>
339
+
Privacy Policy URI
340
+
</label>
341
+
<input
342
+
type="url"
343
+
id="policyUri"
344
+
name="policyUri"
345
+
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
346
+
placeholder="https://example.com/privacy"
347
+
/>
348
+
</div>
349
+
350
+
<div className="flex justify-end gap-3 mt-6">
351
+
<button
352
+
type="button"
353
+
_="on click set #modal-container's innerHTML to ''"
354
+
className="px-4 py-2 bg-gray-200 text-gray-800 rounded hover:bg-gray-300"
355
+
>
356
+
Cancel
357
+
</button>
358
+
<button
359
+
type="submit"
360
+
className="px-4 py-2 bg-blue-600 text-white rounded hover:bg-blue-700"
361
+
>
362
+
Register Client
363
+
</button>
364
+
</div>
365
+
</div>
366
+
</form>
367
+
</div>
368
+
</div>
369
+
);
370
+
}
+17
frontend/src/components/OAuthDeleteResult.tsx
+17
frontend/src/components/OAuthDeleteResult.tsx
···
1
+
interface OAuthDeleteResultProps {
2
+
success: boolean;
3
+
error?: string;
4
+
}
5
+
6
+
export function OAuthDeleteResult({ success, error }: OAuthDeleteResultProps) {
7
+
if (!success) {
8
+
return (
9
+
<div className="text-red-600">
10
+
Failed to delete OAuth client{error ? `: ${error}` : ""}
11
+
</div>
12
+
);
13
+
}
14
+
15
+
// Return empty for successful deletion (removes the row)
16
+
return null;
17
+
}
+55
frontend/src/components/OAuthRegistrationResult.tsx
+55
frontend/src/components/OAuthRegistrationResult.tsx
···
1
+
interface OAuthRegistrationResultProps {
2
+
success: boolean;
3
+
sliceId: string;
4
+
clientId?: string;
5
+
registrationToken?: string;
6
+
error?: string;
7
+
}
8
+
9
+
export function OAuthRegistrationResult({
10
+
success,
11
+
sliceId,
12
+
clientId,
13
+
registrationToken,
14
+
error,
15
+
}: OAuthRegistrationResultProps) {
16
+
if (!success) {
17
+
return (
18
+
<div class="bg-red-100 border border-red-400 text-red-700 px-4 py-3 rounded">
19
+
❌ Failed to register OAuth client: {error}
20
+
</div>
21
+
);
22
+
}
23
+
24
+
return (
25
+
<div class="bg-green-100 border border-green-400 text-green-700 px-4 py-3 rounded mb-4">
26
+
<div class="font-semibold mb-2">
27
+
✅ OAuth client registered successfully!
28
+
</div>
29
+
<div class="mb-2">
30
+
<span class="font-medium">Client ID:</span>{" "}
31
+
<code class="bg-green-200 px-1 rounded">{clientId}</code>
32
+
</div>
33
+
{registrationToken && (
34
+
<div class="bg-yellow-50 border border-yellow-400 text-yellow-800 p-3 rounded mb-3">
35
+
<div class="font-semibold mb-1">
36
+
⚠️ Important: Save this registration access token
37
+
</div>
38
+
<div class="text-sm mb-2">
39
+
This token won't be shown again. Store it securely to manage this
40
+
client.
41
+
</div>
42
+
<code class="block bg-yellow-100 p-2 rounded text-xs break-all">
43
+
{registrationToken}
44
+
</code>
45
+
</div>
46
+
)}
47
+
<a
48
+
href={`/slices/${sliceId}/oauth`}
49
+
class="inline-block mt-4 px-4 py-2 bg-green-600 text-white rounded hover:bg-green-700 text-decoration-none"
50
+
>
51
+
Continue
52
+
</a>
53
+
</div>
54
+
);
55
+
}
+1
frontend/src/components/SliceTabs.tsx
+1
frontend/src/components/SliceTabs.tsx
···
11
11
{ id: "records", name: "Records", href: `/slices/${sliceId}/records` },
12
12
{ id: "sync", name: "Sync", href: `/slices/${sliceId}/sync` },
13
13
{ id: "codegen", name: "Code Gen", href: `/slices/${sliceId}/codegen` },
14
+
{ id: "oauth", name: "OAuth Clients", href: `/slices/${sliceId}/oauth` },
14
15
{ id: "settings", name: "Settings", href: `/slices/${sliceId}/settings` },
15
16
];
16
17
}
+174
frontend/src/pages/SliceOAuthPage.tsx
+174
frontend/src/pages/SliceOAuthPage.tsx
···
1
+
import { Layout } from "../components/Layout.tsx";
2
+
import { SliceTabs } from "../components/SliceTabs.tsx";
3
+
4
+
interface OAuthClient {
5
+
clientId: string;
6
+
createdAt: string;
7
+
clientName?: string;
8
+
redirectUris?: string[];
9
+
}
10
+
11
+
interface SliceOAuthPageProps {
12
+
sliceName?: string;
13
+
sliceId?: string;
14
+
clients?: OAuthClient[];
15
+
currentUser?: { handle?: string; isAuthenticated: boolean };
16
+
error?: string | null;
17
+
success?: string | null;
18
+
}
19
+
20
+
export function SliceOAuthPage({
21
+
sliceName = "My Slice",
22
+
sliceId = "example",
23
+
clients = [],
24
+
currentUser,
25
+
error = null,
26
+
success = null,
27
+
}: SliceOAuthPageProps) {
28
+
return (
29
+
<Layout title={`${sliceName} - OAuth Clients`} currentUser={currentUser}>
30
+
<div>
31
+
<div className="flex items-center justify-between mb-8">
32
+
<div className="flex items-center">
33
+
<a href="/" className="text-blue-600 hover:text-blue-800 mr-4">
34
+
← Back to Slices
35
+
</a>
36
+
<h1 className="text-3xl font-bold text-gray-800">{sliceName}</h1>
37
+
</div>
38
+
</div>
39
+
40
+
{/* Tab Navigation */}
41
+
<SliceTabs sliceId={sliceId} currentTab="oauth" />
42
+
43
+
{/* Success Message */}
44
+
{success && (
45
+
<div className="bg-green-100 border border-green-400 text-green-700 px-4 py-3 rounded mb-4">
46
+
✅ {success}
47
+
</div>
48
+
)}
49
+
50
+
{/* Error Message */}
51
+
{error && (
52
+
<div className="bg-red-100 border border-red-400 text-red-700 px-4 py-3 rounded mb-4">
53
+
❌ {error}
54
+
</div>
55
+
)}
56
+
57
+
{/* OAuth Clients Content */}
58
+
<div className="bg-white rounded-lg shadow-md p-6">
59
+
<div className="flex justify-between items-center mb-6">
60
+
<h2 className="text-2xl font-semibold text-gray-800">
61
+
OAuth Clients
62
+
</h2>
63
+
<button
64
+
type="button"
65
+
hx-get={`/api/slices/${sliceId}/oauth/new`}
66
+
hx-target="#modal-container"
67
+
hx-swap="innerHTML"
68
+
className="bg-blue-600 text-white px-4 py-2 rounded-lg hover:bg-blue-700 transition"
69
+
>
70
+
Register New Client
71
+
</button>
72
+
</div>
73
+
74
+
{clients.length === 0 ? (
75
+
<div className="text-center py-12">
76
+
<p className="text-gray-600 mb-4">
77
+
No OAuth clients registered for this slice.
78
+
</p>
79
+
<button
80
+
type="button"
81
+
hx-get={`/api/slices/${sliceId}/oauth/new`}
82
+
hx-target="#modal-container"
83
+
hx-swap="innerHTML"
84
+
className="text-blue-600 hover:text-blue-800"
85
+
>
86
+
Register your first OAuth client
87
+
</button>
88
+
</div>
89
+
) : (
90
+
<div className="overflow-x-auto">
91
+
<table className="w-full">
92
+
<thead>
93
+
<tr className="border-b">
94
+
<th className="text-left py-2 px-4">Client ID</th>
95
+
<th className="text-left py-2 px-4">Name</th>
96
+
<th className="text-left py-2 px-4">Redirect URIs</th>
97
+
<th className="text-left py-2 px-4">Created</th>
98
+
<th className="text-left py-2 px-4">Actions</th>
99
+
</tr>
100
+
</thead>
101
+
<tbody>
102
+
{clients.map((client) => (
103
+
<tr
104
+
key={client.clientId}
105
+
className="border-b hover:bg-gray-50"
106
+
>
107
+
<td className="py-3 px-4 font-mono text-sm">
108
+
{client.clientId}
109
+
</td>
110
+
<td className="py-3 px-4">
111
+
{client.clientName || "Loading..."}
112
+
</td>
113
+
<td className="py-3 px-4">
114
+
{client.redirectUris ? (
115
+
<div className="text-sm">
116
+
{client.redirectUris.slice(0, 2).map((uri, idx) => (
117
+
<div key={idx} className="truncate max-w-xs">
118
+
{uri}
119
+
</div>
120
+
))}
121
+
{client.redirectUris.length > 2 && (
122
+
<div className="text-gray-500">
123
+
+{client.redirectUris.length - 2} more
124
+
</div>
125
+
)}
126
+
</div>
127
+
) : (
128
+
<span className="text-gray-400">Loading...</span>
129
+
)}
130
+
</td>
131
+
<td className="py-3 px-4 text-sm text-gray-600">
132
+
{new Date(client.createdAt).toLocaleDateString()}
133
+
</td>
134
+
<td className="py-3 px-4">
135
+
<div className="flex gap-2">
136
+
<button
137
+
type="button"
138
+
hx-get={`/api/slices/${sliceId}/oauth/${encodeURIComponent(
139
+
client.clientId
140
+
)}/view`}
141
+
hx-target="#modal-container"
142
+
hx-swap="innerHTML"
143
+
className="text-blue-600 hover:text-blue-800 text-sm"
144
+
>
145
+
View
146
+
</button>
147
+
<button
148
+
type="button"
149
+
hx-delete={`/api/slices/${sliceId}/oauth/${encodeURIComponent(
150
+
client.clientId
151
+
)}`}
152
+
hx-confirm="Are you sure you want to delete this OAuth client?"
153
+
hx-target="closest tr"
154
+
hx-swap="outerHTML"
155
+
className="text-red-600 hover:text-red-800 text-sm"
156
+
>
157
+
Delete
158
+
</button>
159
+
</div>
160
+
</td>
161
+
</tr>
162
+
))}
163
+
</tbody>
164
+
</table>
165
+
</div>
166
+
)}
167
+
</div>
168
+
169
+
{/* Modal Container */}
170
+
<div id="modal-container"></div>
171
+
</div>
172
+
</Layout>
173
+
);
174
+
}
+93
-8
frontend/src/routes/pages.tsx
+93
-8
frontend/src/routes/pages.tsx
···
13
13
import { SliceCodegenPage } from "../pages/SliceCodegenPage.tsx";
14
14
import { SliceApiDocsPage } from "../pages/SliceApiDocsPage.tsx";
15
15
import { SliceSettingsPage } from "../pages/SliceSettingsPage.tsx";
16
+
import { SliceOAuthPage } from "../pages/SliceOAuthPage.tsx";
16
17
import { SyncJobLogsPage } from "../pages/SyncJobLogsPage.tsx";
17
18
import { JetstreamLogsPage } from "../pages/JetstreamLogsPage.tsx";
18
19
import { SettingsPage } from "../pages/SettingsPage.tsx";
···
21
22
async function handleIndexPage(req: Request): Promise<Response> {
22
23
const context = await withAuth(req);
23
24
24
-
// Slice list page - get real slices from AT Protocol
25
25
let slices: Array<{ id: string; name: string; createdAt: string }> = [];
26
26
27
27
if (context.currentUser.isAuthenticated) {
···
92
92
return Response.redirect(new URL("/", req.url), 302);
93
93
}
94
94
95
-
// Get real slice data from AT Protocol
96
95
let sliceData = {
97
96
sliceId,
98
97
sliceName: "Unknown Slice",
···
104
103
105
104
if (context.currentUser.isAuthenticated) {
106
105
try {
107
-
// Construct the full URI for this slice using AT Protocol helpers
108
106
const sliceUri = buildAtUri({
109
107
did: context.currentUser.sub || "unknown",
110
108
collection: "social.slices.slice",
···
117
115
atprotoClient.social.slices.slice.stats({ slice: sliceUri }),
118
116
]);
119
117
120
-
// Transform collection stats to match the interface
121
118
const collections = stats.success
122
119
? stats.collectionStats.map((stat) => ({
123
120
name: stat.collection,
···
224
221
const selectedCollection = url.searchParams.get("collection") || "";
225
222
const selectedAuthor = url.searchParams.get("author") || "";
226
223
const searchQuery = url.searchParams.get("search") || "";
227
-
228
224
229
225
// Fetch real records if a collection is selected
230
226
let records: Array<{
···
582
578
583
579
try {
584
580
const sliceClient = getSliceClient(context, sliceId);
585
-
586
-
const logsResult = await sliceClient.social.slices.slice.getJetstreamLogs({ limit: 100 });
587
-
logs = logsResult.logs.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime());
581
+
582
+
const logsResult = await sliceClient.social.slices.slice.getJetstreamLogs({
583
+
limit: 100,
584
+
});
585
+
logs = logsResult.logs.sort(
586
+
(a, b) =>
587
+
new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()
588
+
);
588
589
} catch (error) {
589
590
console.error("Failed to fetch Jetstream logs:", error);
590
591
}
···
607
608
});
608
609
}
609
610
611
+
async function handleSliceOAuthPage(
612
+
req: Request,
613
+
params?: URLPatternResult
614
+
): Promise<Response> {
615
+
const context = await withAuth(req);
616
+
if (!context.currentUser.isAuthenticated) {
617
+
return new Response("", {
618
+
status: 302,
619
+
headers: { location: "/login" },
620
+
});
621
+
}
622
+
623
+
const sliceId = params?.pathname.groups.id;
624
+
if (!sliceId) {
625
+
return new Response("Invalid slice ID", { status: 400 });
626
+
}
627
+
628
+
// Get the slice record first (separate from OAuth clients)
629
+
const sliceUri = buildAtUri({
630
+
did: context.currentUser.sub!,
631
+
collection: "social.slices.slice",
632
+
rkey: sliceId,
633
+
});
634
+
635
+
const sliceClient = getSliceClient(context, sliceId);
636
+
637
+
let slice;
638
+
try {
639
+
slice = await atprotoClient.social.slices.slice.getRecord({
640
+
uri: sliceUri,
641
+
});
642
+
} catch (error) {
643
+
console.error("Error fetching slice:", error);
644
+
return new Response("Slice not found", { status: 404 });
645
+
}
646
+
647
+
// Try to fetch OAuth clients
648
+
let clientsWithDetails: {
649
+
clientId: string;
650
+
createdAt: string;
651
+
clientName?: string;
652
+
redirectUris?: string[];
653
+
}[] = [];
654
+
let errorMessage = null;
655
+
656
+
try {
657
+
const oauthClientsResponse =
658
+
await sliceClient.social.slices.slice.getOAuthClients();
659
+
console.log("Fetched OAuth clients:", oauthClientsResponse.clients);
660
+
clientsWithDetails = oauthClientsResponse.clients.map((client) => ({
661
+
clientId: client.clientId,
662
+
createdAt: new Date().toISOString(), // Backend should provide this
663
+
clientName: client.clientName,
664
+
redirectUris: client.redirectUris,
665
+
}));
666
+
} catch (oauthError) {
667
+
console.error("Error fetching OAuth clients:", oauthError);
668
+
errorMessage = "Failed to fetch OAuth clients";
669
+
}
670
+
671
+
const html = render(
672
+
<SliceOAuthPage
673
+
sliceName={slice.value.name}
674
+
sliceId={sliceId}
675
+
clients={clientsWithDetails}
676
+
currentUser={context.currentUser}
677
+
error={errorMessage}
678
+
/>
679
+
);
680
+
681
+
const responseHeaders: Record<string, string> = {
682
+
"content-type": "text/html",
683
+
};
684
+
685
+
return new Response(`<!DOCTYPE html>${html}`, {
686
+
status: 200,
687
+
headers: responseHeaders,
688
+
});
689
+
}
690
+
610
691
export const pageRoutes: Route[] = [
611
692
{
612
693
pattern: new URLPattern({ pathname: "/" }),
···
635
716
{
636
717
pattern: new URLPattern({ pathname: "/slices/:id/jetstream/logs" }),
637
718
handler: handleJetstreamLogsPage,
719
+
},
720
+
{
721
+
pattern: new URLPattern({ pathname: "/slices/:id/oauth" }),
722
+
handler: handleSliceOAuthPage,
638
723
},
639
724
{
640
725
pattern: new URLPattern({ pathname: "/slices/:id/:tab" }),
+401
-93
frontend/src/routes/slices.tsx
+401
-93
frontend/src/routes/slices.tsx
···
21
21
import { JetstreamLogs } from "../components/JetstreamLogs.tsx";
22
22
import { buildAtUri } from "../utils/at-uri.ts";
23
23
import { Layout } from "../components/Layout.tsx";
24
+
import { OAuthClientModal } from "../components/OAuthClientModal.tsx";
25
+
import { OAuthRegistrationResult } from "../components/OAuthRegistrationResult.tsx";
26
+
import { OAuthDeleteResult } from "../components/OAuthDeleteResult.tsx";
24
27
25
28
async function handleCreateSlice(req: Request): Promise<Response> {
26
29
const context = await withAuth(req);
···
757
760
758
761
const sliceId = params?.pathname.groups.id;
759
762
const jobId = params?.pathname.groups.jobId;
760
-
763
+
761
764
if (!sliceId || !jobId) {
762
765
const html = render(
763
766
<div className="p-8 text-center text-red-600">
···
913
916
}
914
917
}
915
918
916
-
export const sliceRoutes: Route[] = [
917
-
{
918
-
method: "POST",
919
-
pattern: new URLPattern({ pathname: "/slices" }),
920
-
handler: handleCreateSlice,
921
-
},
922
-
{
923
-
method: "PUT",
924
-
pattern: new URLPattern({ pathname: "/api/slices/:id/settings" }),
925
-
handler: handleUpdateSliceSettings,
926
-
},
927
-
{
928
-
method: "DELETE",
929
-
pattern: new URLPattern({ pathname: "/api/slices/:id" }),
930
-
handler: handleDeleteSlice,
931
-
},
932
-
{
933
-
method: "POST",
934
-
pattern: new URLPattern({ pathname: "/api/lexicons" }),
935
-
handler: handleCreateLexicon,
936
-
},
937
-
{
938
-
method: "GET",
939
-
pattern: new URLPattern({ pathname: "/api/slices/:id/lexicons/list" }),
940
-
handler: handleListLexicons,
941
-
},
942
-
{
943
-
method: "GET",
944
-
pattern: new URLPattern({
945
-
pathname: "/api/slices/:id/lexicons/:rkey/view",
946
-
}),
947
-
handler: handleViewLexicon,
948
-
},
949
-
{
950
-
method: "DELETE",
951
-
pattern: new URLPattern({ pathname: "/api/slices/:id/lexicons/:rkey" }),
952
-
handler: handleDeleteLexicon,
953
-
},
954
-
{
955
-
method: "POST",
956
-
pattern: new URLPattern({ pathname: "/api/slices/:id/codegen" }),
957
-
handler: handleSliceCodegen,
958
-
},
959
-
{
960
-
method: "POST",
961
-
pattern: new URLPattern({ pathname: "/api/slices/:id/lexicons" }),
962
-
handler: handleCreateLexicon,
963
-
},
964
-
{
965
-
method: "PUT",
966
-
pattern: new URLPattern({ pathname: "/api/profile" }),
967
-
handler: handleUpdateProfile,
968
-
},
969
-
{
970
-
method: "POST",
971
-
pattern: new URLPattern({ pathname: "/api/slices/:id/sync" }),
972
-
handler: handleSliceSync,
973
-
},
974
-
{
975
-
method: "GET",
976
-
pattern: new URLPattern({ pathname: "/api/slices/:id/job-history" }),
977
-
handler: handleJobHistory,
978
-
},
979
-
{
980
-
method: "GET",
981
-
pattern: new URLPattern({ pathname: "/api/slices/:id/sync/logs/:jobId" }),
982
-
handler: handleSyncJobLogs,
983
-
},
984
-
{
985
-
method: "GET",
986
-
pattern: new URLPattern({ pathname: "/api/jetstream/status" }),
987
-
handler: handleJetstreamStatus,
988
-
},
989
-
{
990
-
method: "GET",
991
-
pattern: new URLPattern({ pathname: "/api/slices/:id/jetstream/logs" }),
992
-
handler: handleJetstreamLogs,
993
-
},
994
-
];
919
+
async function handleOAuthClientNew(req: Request): Promise<Response> {
920
+
const context = await withAuth(req);
921
+
const authResponse = requireAuth(context);
922
+
if (authResponse) return authResponse;
923
+
924
+
const url = new URL(req.url);
925
+
const sliceId = url.pathname.split("/")[3];
926
+
927
+
try {
928
+
// Build the slice URI
929
+
const sliceUri = buildSliceUri(context.currentUser.sub!, sliceId);
930
+
931
+
const html = render(
932
+
<OAuthClientModal sliceId={sliceId} sliceUri={sliceUri} mode="new" />
933
+
);
934
+
935
+
return new Response(html, {
936
+
status: 200,
937
+
headers: { "content-type": "text/html" },
938
+
});
939
+
} catch (error) {
940
+
console.error("Error:", error);
941
+
return new Response("Failed to load modal", { status: 500 });
942
+
}
943
+
}
944
+
945
+
async function handleOAuthClientRegister(req: Request): Promise<Response> {
946
+
const context = await withAuth(req);
947
+
const authResponse = requireAuth(context);
948
+
if (authResponse) return authResponse;
949
+
950
+
const url = new URL(req.url);
951
+
const sliceId = url.pathname.split("/")[3];
952
+
953
+
try {
954
+
const formData = await req.formData();
955
+
const sliceUri = formData.get("sliceUri") as string;
956
+
const clientName = formData.get("clientName") as string;
957
+
const redirectUris = (formData.get("redirectUris") as string)
958
+
.split("\n")
959
+
.map((uri) => uri.trim())
960
+
.filter((uri) => uri.length > 0);
961
+
const scope = (formData.get("scope") as string) || undefined;
962
+
const clientUri = (formData.get("clientUri") as string) || undefined;
963
+
const logoUri = (formData.get("logoUri") as string) || undefined;
964
+
const tosUri = (formData.get("tosUri") as string) || undefined;
965
+
const policyUri = (formData.get("policyUri") as string) || undefined;
966
+
967
+
// Create OAuth client via backend API
968
+
const sliceClient = getSliceClient(context, sliceId);
969
+
const clientDetails =
970
+
await sliceClient.social.slices.slice.createOAuthClient({
971
+
clientName,
972
+
redirectUris,
973
+
grantTypes: ["authorization_code"],
974
+
responseTypes: ["code"],
975
+
...(scope && { scope }),
976
+
...(clientUri && { clientUri }),
977
+
...(logoUri && { logoUri }),
978
+
...(tosUri && { tosUri }),
979
+
...(policyUri && { policyUri }),
980
+
});
981
+
982
+
// Return success response using JSX component
983
+
const html = render(
984
+
<OAuthRegistrationResult
985
+
success
986
+
sliceId={sliceId}
987
+
clientId={clientDetails.clientId}
988
+
/>
989
+
);
990
+
991
+
return new Response(html, {
992
+
status: 200,
993
+
headers: { "content-type": "text/html" },
994
+
});
995
+
} catch (error) {
996
+
console.error("Error registering OAuth client:", error);
997
+
const html = render(
998
+
<OAuthRegistrationResult
999
+
success={false}
1000
+
sliceId={sliceId}
1001
+
error={error instanceof Error ? error.message : String(error)}
1002
+
/>
1003
+
);
1004
+
1005
+
return new Response(html, {
1006
+
status: 200,
1007
+
headers: { "content-type": "text/html" },
1008
+
});
1009
+
}
1010
+
}
1011
+
1012
+
async function handleOAuthClientDelete(req: Request): Promise<Response> {
1013
+
const context = await withAuth(req);
1014
+
const authResponse = requireAuth(context);
1015
+
if (authResponse) return authResponse;
1016
+
1017
+
const url = new URL(req.url);
1018
+
const pathParts = url.pathname.split("/");
1019
+
const sliceId = pathParts[3];
1020
+
const clientId = decodeURIComponent(pathParts[pathParts.length - 1]);
1021
+
1022
+
try {
1023
+
const sliceClient = getSliceClient(context, sliceId);
1024
+
1025
+
// Delete the OAuth client via backend API
1026
+
await sliceClient.social.slices.slice.deleteOAuthClient(clientId);
1027
+
1028
+
// Return empty response to remove the row
1029
+
const html = render(<OAuthDeleteResult success />);
1030
+
return new Response(html || "", {
1031
+
status: 200,
1032
+
headers: { "content-type": "text/html" },
1033
+
});
1034
+
} catch (error) {
1035
+
console.error("Error deleting OAuth client:", error);
1036
+
const html = render(
1037
+
<OAuthDeleteResult
1038
+
success={false}
1039
+
error={error instanceof Error ? error.message : String(error)}
1040
+
/>
1041
+
);
1042
+
return new Response(html || "", {
1043
+
status: 200,
1044
+
headers: { "content-type": "text/html" },
1045
+
});
1046
+
}
1047
+
}
1048
+
1049
+
async function handleOAuthClientView(req: Request): Promise<Response> {
1050
+
const context = await withAuth(req);
1051
+
const authResponse = requireAuth(context);
1052
+
if (authResponse) return authResponse;
1053
+
1054
+
const url = new URL(req.url);
1055
+
const pathParts = url.pathname.split("/");
1056
+
const sliceId = pathParts[3];
1057
+
const clientId = decodeURIComponent(pathParts[5]);
1058
+
1059
+
try {
1060
+
const sliceClient = getSliceClient(context, sliceId);
1061
+
1062
+
// Get OAuth clients to find the specific one
1063
+
const clients = await sliceClient.social.slices.slice.getOAuthClients();
1064
+
const client = clients.clients.find((c) => c.clientId === clientId);
1065
+
1066
+
if (!client) {
1067
+
const html = render(
1068
+
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center p-4">
1069
+
<div className="bg-white rounded-lg shadow-lg p-6 max-w-lg w-full">
1070
+
<h2 className="text-xl font-semibold text-gray-800 mb-4">
1071
+
OAuth Client Not Found
1072
+
</h2>
1073
+
<p className="text-gray-600 mb-4">
1074
+
The requested OAuth client could not be found.
1075
+
</p>
1076
+
<button
1077
+
type="button"
1078
+
_="on click set #modal-container's innerHTML to ''"
1079
+
className="bg-gray-500 text-white px-4 py-2 rounded-lg hover:bg-gray-600 transition"
1080
+
>
1081
+
Close
1082
+
</button>
1083
+
</div>
1084
+
</div>
1085
+
);
1086
+
return new Response(html || "", {
1087
+
status: 404,
1088
+
headers: { "content-type": "text/html" },
1089
+
});
1090
+
}
1091
+
1092
+
const sliceUri = `at://did:plc:bcgltzqazw5tb6k2g3ttenbj/social.slices.slice/${sliceId}`;
1093
+
const html = render(
1094
+
<OAuthClientModal
1095
+
sliceId={sliceId}
1096
+
sliceUri={sliceUri}
1097
+
mode="view"
1098
+
clientData={client}
1099
+
/>
1100
+
);
1101
+
1102
+
return new Response(html || "", {
1103
+
status: 200,
1104
+
headers: { "content-type": "text/html" },
1105
+
});
1106
+
} catch (error) {
1107
+
console.error("Error viewing OAuth client:", error);
1108
+
const html = render(
1109
+
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center p-4">
1110
+
<div className="bg-white rounded-lg shadow-lg p-6 max-w-lg w-full">
1111
+
<h2 className="text-xl font-semibold text-gray-800 mb-4">Error</h2>
1112
+
<p className="text-gray-600 mb-4">
1113
+
Failed to load OAuth client details:{" "}
1114
+
{error instanceof Error ? error.message : String(error)}
1115
+
</p>
1116
+
<button
1117
+
type="button"
1118
+
_="on click set #modal-container's innerHTML to ''"
1119
+
className="bg-gray-500 text-white px-4 py-2 rounded-lg hover:bg-gray-600 transition"
1120
+
>
1121
+
Close
1122
+
</button>
1123
+
</div>
1124
+
</div>
1125
+
);
1126
+
return new Response(html || "", {
1127
+
status: 500,
1128
+
headers: { "content-type": "text/html" },
1129
+
});
1130
+
}
1131
+
}
1132
+
1133
+
async function handleOAuthClientUpdate(req: Request): Promise<Response> {
1134
+
const context = await withAuth(req);
1135
+
const authResponse = requireAuth(context);
1136
+
if (authResponse) return authResponse;
1137
+
1138
+
const url = new URL(req.url);
1139
+
const pathParts = url.pathname.split("/");
1140
+
const sliceId = pathParts[3];
1141
+
const clientId = decodeURIComponent(pathParts[5]);
1142
+
1143
+
try {
1144
+
const formData = await req.formData();
1145
+
const clientName = formData.get("clientName") as string;
1146
+
const redirectUrisText = formData.get("redirectUris") as string;
1147
+
const scope = formData.get("scope") as string;
1148
+
const clientUri = formData.get("clientUri") as string;
1149
+
const logoUri = formData.get("logoUri") as string;
1150
+
const tosUri = formData.get("tosUri") as string;
1151
+
const policyUri = formData.get("policyUri") as string;
1152
+
1153
+
// Parse redirect URIs (split by lines and filter empty)
1154
+
const redirectUris = redirectUrisText
1155
+
.split("\n")
1156
+
.map((uri) => uri.trim())
1157
+
.filter((uri) => uri.length > 0);
1158
+
1159
+
// Update OAuth client via backend API
1160
+
const sliceClient = getSliceClient(context, sliceId);
1161
+
const updatedClient =
1162
+
await sliceClient.social.slices.slice.updateOAuthClient({
1163
+
clientId,
1164
+
clientName: clientName || undefined,
1165
+
redirectUris: redirectUris.length > 0 ? redirectUris : undefined,
1166
+
scope: scope || undefined,
1167
+
clientUri: clientUri || undefined,
1168
+
logoUri: logoUri || undefined,
1169
+
tosUri: tosUri || undefined,
1170
+
policyUri: policyUri || undefined,
1171
+
});
1172
+
1173
+
const sliceUri = `at://did:plc:bcgltzqazw5tb6k2g3ttenbj/social.slices.slice/${sliceId}`;
1174
+
const html = render(
1175
+
<OAuthClientModal
1176
+
sliceId={sliceId}
1177
+
sliceUri={sliceUri}
1178
+
mode="view"
1179
+
clientData={updatedClient}
1180
+
/>
1181
+
);
1182
+
return new Response(html, {
1183
+
status: 200,
1184
+
headers: { "content-type": "text/html" },
1185
+
});
1186
+
} catch (error) {
1187
+
console.error("Error updating OAuth client:", error);
1188
+
const html = render(
1189
+
<OAuthDeleteResult
1190
+
success={false}
1191
+
error={error instanceof Error ? error.message : String(error)}
1192
+
/>
1193
+
);
1194
+
return new Response(html, {
1195
+
status: 500,
1196
+
headers: { "content-type": "text/html" },
1197
+
});
1198
+
}
1199
+
}
995
1200
996
1201
async function handleJetstreamLogs(
997
1202
req: Request,
···
1004
1209
const sliceId = params?.pathname.groups.id;
1005
1210
if (!sliceId) {
1006
1211
const html = render(
1007
-
<div className="p-8 text-center text-red-600">
1008
-
❌ Invalid slice ID
1009
-
</div>
1212
+
<div className="p-8 text-center text-red-600">❌ Invalid slice ID</div>
1010
1213
);
1011
1214
return new Response(html, {
1012
1215
status: 400,
···
1024
1227
});
1025
1228
1026
1229
const logs = result?.logs || [];
1027
-
1028
-
// Sort logs in descending order (newest first)
1029
-
const sortedLogs = logs.sort((a, b) =>
1030
-
new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()
1230
+
1231
+
// Sort logs in descending order (newest first)
1232
+
const sortedLogs = logs.sort(
1233
+
(a, b) =>
1234
+
new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()
1031
1235
);
1032
1236
1033
1237
// Render the log content
···
1067
1271
}
1068
1272
}
1069
1273
1070
-
1071
1274
async function handleJetstreamStatus(
1072
1275
req: Request,
1073
1276
_params?: URLPatternResult
···
1077
1280
const url = new URL(req.url);
1078
1281
const sliceId = url.searchParams.get("sliceId");
1079
1282
const isCompact = url.searchParams.get("compact") === "true";
1080
-
1283
+
1081
1284
// Fetch jetstream status using the atproto client
1082
1285
const data = await atprotoClient.social.slices.slice.getJetstreamStatus();
1083
1286
···
1098
1301
)}
1099
1302
</div>
1100
1303
);
1101
-
1304
+
1102
1305
return new Response(html, {
1103
1306
status: 200,
1104
1307
headers: { "content-type": "text/html" },
···
1124
1327
const url = new URL(req.url);
1125
1328
const sliceId = url.searchParams.get("sliceId");
1126
1329
const isCompact = url.searchParams.get("compact") === "true";
1127
-
1330
+
1128
1331
// Render compact error version
1129
1332
if (isCompact) {
1130
1333
const html = render(
···
1133
1336
<span className="text-red-700">Jetstream Offline</span>
1134
1337
</div>
1135
1338
);
1136
-
1339
+
1137
1340
return new Response(html, {
1138
1341
status: 200,
1139
1342
headers: { "content-type": "text/html" },
1140
1343
});
1141
1344
}
1142
-
1345
+
1143
1346
// Fallback to disconnected state on error for full version
1144
1347
const html = render(
1145
1348
<JetstreamStatus
···
1156
1359
});
1157
1360
}
1158
1361
}
1362
+
1363
+
export const sliceRoutes: Route[] = [
1364
+
{
1365
+
method: "POST",
1366
+
pattern: new URLPattern({ pathname: "/slices" }),
1367
+
handler: handleCreateSlice,
1368
+
},
1369
+
{
1370
+
method: "PUT",
1371
+
pattern: new URLPattern({ pathname: "/api/slices/:id/settings" }),
1372
+
handler: handleUpdateSliceSettings,
1373
+
},
1374
+
{
1375
+
method: "DELETE",
1376
+
pattern: new URLPattern({ pathname: "/api/slices/:id" }),
1377
+
handler: handleDeleteSlice,
1378
+
},
1379
+
{
1380
+
method: "POST",
1381
+
pattern: new URLPattern({ pathname: "/api/lexicons" }),
1382
+
handler: handleCreateLexicon,
1383
+
},
1384
+
{
1385
+
method: "GET",
1386
+
pattern: new URLPattern({ pathname: "/api/slices/:id/lexicons/list" }),
1387
+
handler: handleListLexicons,
1388
+
},
1389
+
{
1390
+
method: "GET",
1391
+
pattern: new URLPattern({
1392
+
pathname: "/api/slices/:id/lexicons/:rkey/view",
1393
+
}),
1394
+
handler: handleViewLexicon,
1395
+
},
1396
+
{
1397
+
method: "DELETE",
1398
+
pattern: new URLPattern({ pathname: "/api/slices/:id/lexicons/:rkey" }),
1399
+
handler: handleDeleteLexicon,
1400
+
},
1401
+
{
1402
+
method: "POST",
1403
+
pattern: new URLPattern({ pathname: "/api/slices/:id/codegen" }),
1404
+
handler: handleSliceCodegen,
1405
+
},
1406
+
{
1407
+
method: "POST",
1408
+
pattern: new URLPattern({ pathname: "/api/slices/:id/lexicons" }),
1409
+
handler: handleCreateLexicon,
1410
+
},
1411
+
{
1412
+
method: "PUT",
1413
+
pattern: new URLPattern({ pathname: "/api/profile" }),
1414
+
handler: handleUpdateProfile,
1415
+
},
1416
+
{
1417
+
method: "POST",
1418
+
pattern: new URLPattern({ pathname: "/api/slices/:id/sync" }),
1419
+
handler: handleSliceSync,
1420
+
},
1421
+
{
1422
+
method: "GET",
1423
+
pattern: new URLPattern({ pathname: "/api/slices/:id/job-history" }),
1424
+
handler: handleJobHistory,
1425
+
},
1426
+
{
1427
+
method: "GET",
1428
+
pattern: new URLPattern({ pathname: "/api/slices/:id/sync/logs/:jobId" }),
1429
+
handler: handleSyncJobLogs,
1430
+
},
1431
+
{
1432
+
method: "GET",
1433
+
pattern: new URLPattern({ pathname: "/api/jetstream/status" }),
1434
+
handler: handleJetstreamStatus,
1435
+
},
1436
+
{
1437
+
method: "GET",
1438
+
pattern: new URLPattern({ pathname: "/api/slices/:id/jetstream/logs" }),
1439
+
handler: handleJetstreamLogs,
1440
+
},
1441
+
{
1442
+
method: "GET",
1443
+
pattern: new URLPattern({ pathname: "/api/slices/:id/oauth/new" }),
1444
+
handler: handleOAuthClientNew,
1445
+
},
1446
+
{
1447
+
method: "POST",
1448
+
pattern: new URLPattern({ pathname: "/api/slices/:id/oauth/register" }),
1449
+
handler: handleOAuthClientRegister,
1450
+
},
1451
+
{
1452
+
method: "GET",
1453
+
pattern: new URLPattern({ pathname: "/api/slices/:id/oauth/:uri/view" }),
1454
+
handler: handleOAuthClientView,
1455
+
},
1456
+
{
1457
+
method: "POST",
1458
+
pattern: new URLPattern({ pathname: "/api/slices/:id/oauth/:uri/update" }),
1459
+
handler: handleOAuthClientUpdate,
1460
+
},
1461
+
{
1462
+
method: "DELETE",
1463
+
pattern: new URLPattern({ pathname: "/api/slices/:id/oauth/:uri" }),
1464
+
handler: handleOAuthClientDelete,
1465
+
},
1466
+
];