@feltdb/core 0.8.1 → 0.8.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/create/package-versions.js +1 -1
- package/dist/create/server-source/crates/feltdb/src/application.rs +19 -0
- package/dist/create/server-source/crates/feltdb/src/authority.rs +349 -0
- package/dist/create/server-source/crates/feltdb/src/bin/feltdb-authority-client.rs +32 -0
- package/dist/create/server-source/crates/feltdb/src/bin/feltdb-authority.rs +19 -0
- package/dist/create/server-source/crates/feltdb/src/lib.rs +444 -189
- package/dist/create/server-source/crates/feltdb/src/state_contract.rs +244 -51
- package/dist/create/server-source/crates/feltdb/tests/authority_process.rs +297 -0
- package/dist/create/server-source/crates/feltdb-server/src/auth.rs +41 -11
- package/dist/create/server-source/crates/feltdb-server/src/key_management.rs +8 -3
- package/dist/create/server-source/crates/feltdb-server/src/main.rs +1164 -225
- package/dist/create/server-source/crates/feltdb-server/src/tenancy.rs +134 -0
- package/dist/file-db.d.ts +8 -15
- package/dist/file-db.d.ts.map +1 -1
- package/dist/file-db.js +234 -130
- package/dist/http-db.d.ts +3 -0
- package/dist/http-db.d.ts.map +1 -1
- package/dist/http-db.js +2 -1
- package/dist/state-contract.d.ts +4 -1
- package/dist/state-contract.d.ts.map +1 -1
- package/dist/state-contract.js +1 -1
- package/dist/studio-app/assets/{feltdb_wasm-bIqcRzAr.js → feltdb_wasm-DB8cX151.js} +1 -1
- package/dist/studio-app/assets/feltdb_wasm_bg-ClhDHp0S.wasm +0 -0
- package/dist/studio-app/assets/{index-XGYdlElN.js → index-B0k4UAlI.js} +1 -1
- package/dist/studio-app/index.html +1 -1
- package/dist/wasm/feltdb_wasm_bg.wasm +0 -0
- package/package.json +1 -1
- package/dist/studio-app/assets/feltdb_wasm_bg-BE79okwX.wasm +0 -0
|
@@ -96,7 +96,7 @@ use feltdb_server::{
|
|
|
96
96
|
ScalingPolicy, SecretReference as DeploymentSecretReference,
|
|
97
97
|
},
|
|
98
98
|
sessions::SessionVerifier,
|
|
99
|
-
tenancy::{MembershipStatus, TenancyStore},
|
|
99
|
+
tenancy::{MembershipStatus, QualificationMarker, TenancyStore},
|
|
100
100
|
};
|
|
101
101
|
use serde::{Deserialize, Serialize};
|
|
102
102
|
use serde_json::{json, Map, Value};
|
|
@@ -151,11 +151,15 @@ fn api_error_body(status: StatusCode, message: String) -> Value {
|
|
|
151
151
|
let object = body
|
|
152
152
|
.as_object_mut()
|
|
153
153
|
.expect("normalized error body is an object");
|
|
154
|
-
let resolved_code =
|
|
155
|
-
.
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
154
|
+
let resolved_code = if status == StatusCode::FORBIDDEN {
|
|
155
|
+
code.to_string()
|
|
156
|
+
} else {
|
|
157
|
+
object
|
|
158
|
+
.get("code")
|
|
159
|
+
.and_then(Value::as_str)
|
|
160
|
+
.unwrap_or(code)
|
|
161
|
+
.to_string()
|
|
162
|
+
};
|
|
159
163
|
object.insert("error".into(), json!(resolved_code));
|
|
160
164
|
object.insert("code".into(), json!(resolved_code));
|
|
161
165
|
object.entry("message").or_insert_with(|| {
|
|
@@ -230,8 +234,15 @@ struct CreateTenantRequest {
|
|
|
230
234
|
#[derive(Deserialize)]
|
|
231
235
|
struct CreateApplicationRequest {
|
|
232
236
|
name: String,
|
|
237
|
+
#[serde(default)]
|
|
238
|
+
qualification: Option<QualificationMarker>,
|
|
233
239
|
}
|
|
234
240
|
#[derive(Deserialize)]
|
|
241
|
+
struct QualificationDestroyRequest {
|
|
242
|
+
proof_run_id: String,
|
|
243
|
+
}
|
|
244
|
+
#[derive(Deserialize)]
|
|
245
|
+
#[serde(deny_unknown_fields)]
|
|
235
246
|
struct UpdateApplicationRequest {
|
|
236
247
|
name: String,
|
|
237
248
|
}
|
|
@@ -2348,7 +2359,12 @@ async fn create_application_control(
|
|
|
2348
2359
|
) -> Result<(StatusCode, Json<Value>), ApiError> {
|
|
2349
2360
|
let app = state
|
|
2350
2361
|
.tenancy
|
|
2351
|
-
.
|
|
2362
|
+
.create_application_with_qualification(
|
|
2363
|
+
&principal.key_id,
|
|
2364
|
+
&tenant_id,
|
|
2365
|
+
&input.name,
|
|
2366
|
+
input.qualification,
|
|
2367
|
+
)
|
|
2352
2368
|
.map_err(control_error)?;
|
|
2353
2369
|
Ok((StatusCode::CREATED, Json(json!(app))))
|
|
2354
2370
|
}
|
|
@@ -2378,6 +2394,23 @@ async fn delete_application_control(
|
|
|
2378
2394
|
Ok(StatusCode::NO_CONTENT)
|
|
2379
2395
|
}
|
|
2380
2396
|
|
|
2397
|
+
async fn delete_qualification_application_control(
|
|
2398
|
+
State(state): State<AppState>,
|
|
2399
|
+
Extension(principal): Extension<Principal>,
|
|
2400
|
+
Path(application_id): Path<String>,
|
|
2401
|
+
Json(input): Json<QualificationDestroyRequest>,
|
|
2402
|
+
) -> Result<StatusCode, ApiError> {
|
|
2403
|
+
state
|
|
2404
|
+
.tenancy
|
|
2405
|
+
.delete_qualification_application(&principal.key_id, &application_id, &input.proof_run_id)
|
|
2406
|
+
.map_err(control_error)?;
|
|
2407
|
+
state
|
|
2408
|
+
.applications
|
|
2409
|
+
.purge_application(&application_id)
|
|
2410
|
+
.map_err(control_error)?;
|
|
2411
|
+
Ok(StatusCode::NO_CONTENT)
|
|
2412
|
+
}
|
|
2413
|
+
|
|
2381
2414
|
async fn delete_certification_fixture(
|
|
2382
2415
|
State(state): State<AppState>,
|
|
2383
2416
|
Path(application_id): Path<String>,
|
|
@@ -5083,52 +5116,292 @@ const INTENT_COLLECTION: &str = "_feltdb_intents";
|
|
|
5083
5116
|
const INTENT_EVENT_COLLECTION: &str = "_feltdb_intent_events";
|
|
5084
5117
|
|
|
5085
5118
|
fn require_intent_service(headers: &HeaderMap) -> Result<(), ApiError> {
|
|
5086
|
-
let expected = std::env::var("FELTDB_INTENT_SERVICE_SECRET").map_err(|_|
|
|
5087
|
-
|
|
5119
|
+
let expected = std::env::var("FELTDB_INTENT_SERVICE_SECRET").map_err(|_| {
|
|
5120
|
+
inspection_error(
|
|
5121
|
+
StatusCode::SERVICE_UNAVAILABLE,
|
|
5122
|
+
"intent_service_unconfigured",
|
|
5123
|
+
"Intent Engine service authentication is not configured",
|
|
5124
|
+
)
|
|
5125
|
+
})?;
|
|
5126
|
+
let supplied = headers
|
|
5127
|
+
.get("x-feltdb-intent-service")
|
|
5128
|
+
.and_then(|value| value.to_str().ok())
|
|
5129
|
+
.unwrap_or_default();
|
|
5088
5130
|
if Sha256::digest(supplied.as_bytes()) != Sha256::digest(expected.as_bytes()) {
|
|
5089
|
-
return Err(inspection_error(
|
|
5131
|
+
return Err(inspection_error(
|
|
5132
|
+
StatusCode::FORBIDDEN,
|
|
5133
|
+
"forbidden",
|
|
5134
|
+
"invalid Intent Engine service credential",
|
|
5135
|
+
));
|
|
5090
5136
|
}
|
|
5091
5137
|
Ok(())
|
|
5092
5138
|
}
|
|
5093
5139
|
|
|
5094
5140
|
async fn execute_intent(
|
|
5095
|
-
State(state): State<AppState>,
|
|
5141
|
+
State(state): State<AppState>,
|
|
5142
|
+
Extension(principal): Extension<Principal>,
|
|
5143
|
+
headers: HeaderMap,
|
|
5096
5144
|
Json(input): Json<ExecuteIntentRequest>,
|
|
5097
5145
|
) -> Result<Json<Value>, ApiError> {
|
|
5098
|
-
application_scope(
|
|
5099
|
-
|
|
5100
|
-
|
|
5101
|
-
|
|
5102
|
-
|
|
5103
|
-
|
|
5104
|
-
|
|
5105
|
-
|
|
5106
|
-
|
|
5146
|
+
application_scope(
|
|
5147
|
+
&state,
|
|
5148
|
+
&principal.key_id,
|
|
5149
|
+
&input.application_id,
|
|
5150
|
+
"application:write",
|
|
5151
|
+
)?;
|
|
5152
|
+
if input.request.trim().is_empty() {
|
|
5153
|
+
return Err(inspection_error(
|
|
5154
|
+
StatusCode::BAD_REQUEST,
|
|
5155
|
+
"invalid_intent",
|
|
5156
|
+
"request is required",
|
|
5157
|
+
));
|
|
5158
|
+
}
|
|
5159
|
+
let endpoint = std::env::var("FELTDB_INTENT_ENGINE_URL").map_err(|_| {
|
|
5160
|
+
inspection_error(
|
|
5161
|
+
StatusCode::SERVICE_UNAVAILABLE,
|
|
5162
|
+
"intent_engine_unavailable",
|
|
5163
|
+
"managed Intent Engine is not configured",
|
|
5164
|
+
)
|
|
5165
|
+
})?;
|
|
5166
|
+
let secret = std::env::var("FELTDB_INTENT_SERVICE_SECRET").map_err(|_| {
|
|
5167
|
+
inspection_error(
|
|
5168
|
+
StatusCode::SERVICE_UNAVAILABLE,
|
|
5169
|
+
"intent_engine_unavailable",
|
|
5170
|
+
"managed Intent Engine authentication is not configured",
|
|
5171
|
+
)
|
|
5172
|
+
})?;
|
|
5173
|
+
let mut request = state
|
|
5174
|
+
.peer_client
|
|
5175
|
+
.post(format!(
|
|
5176
|
+
"{}/internal/v1/intents",
|
|
5177
|
+
endpoint.trim_end_matches('/')
|
|
5178
|
+
))
|
|
5179
|
+
.header("x-feltdb-intent-service", secret)
|
|
5180
|
+
.json(&input);
|
|
5181
|
+
if let Some(value) = headers.get("idempotency-key") {
|
|
5182
|
+
request = request.header("idempotency-key", value);
|
|
5183
|
+
}
|
|
5184
|
+
let response = request.send().await.map_err(|error| {
|
|
5185
|
+
inspection_error(
|
|
5186
|
+
StatusCode::BAD_GATEWAY,
|
|
5187
|
+
"intent_engine_unavailable",
|
|
5188
|
+
error.to_string(),
|
|
5189
|
+
)
|
|
5190
|
+
})?;
|
|
5191
|
+
let status = response.status();
|
|
5192
|
+
let value: Value = response.json().await.map_err(|error| {
|
|
5193
|
+
inspection_error(
|
|
5194
|
+
StatusCode::BAD_GATEWAY,
|
|
5195
|
+
"invalid_intent_engine_response",
|
|
5196
|
+
error.to_string(),
|
|
5197
|
+
)
|
|
5198
|
+
})?;
|
|
5199
|
+
if !status.is_success() {
|
|
5200
|
+
let republish =
|
|
5201
|
+
value.get("error").and_then(Value::as_str) == Some("APPLICATION_REPUBLISH_REQUIRED");
|
|
5202
|
+
return Err(inspection_error(
|
|
5203
|
+
if republish {
|
|
5204
|
+
StatusCode::CONFLICT
|
|
5205
|
+
} else {
|
|
5206
|
+
StatusCode::BAD_GATEWAY
|
|
5207
|
+
},
|
|
5208
|
+
if republish {
|
|
5209
|
+
"application_republish_required"
|
|
5210
|
+
} else {
|
|
5211
|
+
"intent_execution_failed"
|
|
5212
|
+
},
|
|
5213
|
+
value
|
|
5214
|
+
.get("message")
|
|
5215
|
+
.and_then(Value::as_str)
|
|
5216
|
+
.unwrap_or("Intent Engine failed"),
|
|
5217
|
+
));
|
|
5218
|
+
}
|
|
5107
5219
|
Ok(Json(value))
|
|
5108
5220
|
}
|
|
5109
5221
|
|
|
5110
|
-
async fn list_intents(
|
|
5111
|
-
|
|
5112
|
-
|
|
5113
|
-
|
|
5222
|
+
async fn list_intents(
|
|
5223
|
+
State(state): State<AppState>,
|
|
5224
|
+
Extension(principal): Extension<Principal>,
|
|
5225
|
+
Query(input): Query<ProposalScopeRequest>,
|
|
5226
|
+
) -> Result<Json<Value>, ApiError> {
|
|
5227
|
+
application_scope(
|
|
5228
|
+
&state,
|
|
5229
|
+
&principal.key_id,
|
|
5230
|
+
&input.application_id,
|
|
5231
|
+
"application:read",
|
|
5232
|
+
)?;
|
|
5233
|
+
let mut intents = state
|
|
5234
|
+
.db
|
|
5235
|
+
.list_collection(INTENT_COLLECTION)?
|
|
5236
|
+
.into_iter()
|
|
5237
|
+
.map(|row| row.value)
|
|
5238
|
+
.filter(|value| proposal_scope_matches(value, &input.application_id, &input.environment))
|
|
5239
|
+
.collect::<Vec<_>>();
|
|
5240
|
+
intents.sort_by(|a, b| {
|
|
5241
|
+
a.get("created_at")
|
|
5242
|
+
.and_then(Value::as_str)
|
|
5243
|
+
.cmp(&b.get("created_at").and_then(Value::as_str))
|
|
5244
|
+
});
|
|
5245
|
+
for value in &mut intents {
|
|
5246
|
+
if let Some(object) = value.as_object_mut() {
|
|
5247
|
+
object.remove("execution_result");
|
|
5248
|
+
}
|
|
5249
|
+
redact_inspection_value(value);
|
|
5250
|
+
}
|
|
5251
|
+
Ok(Json(json!({"intents":intents})))
|
|
5252
|
+
}
|
|
5253
|
+
async fn get_intent(
|
|
5254
|
+
State(state): State<AppState>,
|
|
5255
|
+
Extension(principal): Extension<Principal>,
|
|
5256
|
+
Path((id, operation)): Path<(String, String)>,
|
|
5257
|
+
Query(input): Query<ProposalScopeRequest>,
|
|
5258
|
+
) -> Result<Json<Value>, ApiError> {
|
|
5259
|
+
application_scope(
|
|
5260
|
+
&state,
|
|
5261
|
+
&principal.key_id,
|
|
5262
|
+
&input.application_id,
|
|
5263
|
+
"application:read",
|
|
5264
|
+
)?;
|
|
5265
|
+
let intent = required_value(&state, INTENT_COLLECTION, &id, "intent not found")?;
|
|
5266
|
+
if !proposal_scope_matches(&intent, &input.application_id, &input.environment) {
|
|
5267
|
+
return Err(ApiError(StatusCode::NOT_FOUND, "intent not found".into()));
|
|
5268
|
+
}
|
|
5269
|
+
let value = match operation.as_str() {
|
|
5270
|
+
"understanding" => intent.get("understanding").cloned().unwrap_or(Value::Null),
|
|
5271
|
+
"plan" => intent.get("plan").cloned().unwrap_or(Value::Null),
|
|
5272
|
+
"proposal" => intent
|
|
5273
|
+
.get("proposal_id")
|
|
5274
|
+
.and_then(Value::as_str)
|
|
5275
|
+
.map(|id| proposal_value(&state, id))
|
|
5276
|
+
.transpose()?
|
|
5277
|
+
.unwrap_or(Value::Null),
|
|
5278
|
+
"history" => {
|
|
5279
|
+
let mut events = state
|
|
5280
|
+
.db
|
|
5281
|
+
.list_collection(INTENT_EVENT_COLLECTION)?
|
|
5282
|
+
.into_iter()
|
|
5283
|
+
.map(|row| row.value)
|
|
5284
|
+
.filter(|value| value.get("intent_id").and_then(Value::as_str) == Some(id.as_str()))
|
|
5285
|
+
.collect::<Vec<_>>();
|
|
5286
|
+
events.sort_by(|a, b| {
|
|
5287
|
+
a.get("created_at")
|
|
5288
|
+
.and_then(Value::as_str)
|
|
5289
|
+
.cmp(&b.get("created_at").and_then(Value::as_str))
|
|
5290
|
+
});
|
|
5291
|
+
json!({"events":events})
|
|
5292
|
+
}
|
|
5293
|
+
_ => {
|
|
5294
|
+
return Err(ApiError(
|
|
5295
|
+
StatusCode::NOT_FOUND,
|
|
5296
|
+
"intent resource not found".into(),
|
|
5297
|
+
))
|
|
5298
|
+
}
|
|
5299
|
+
};
|
|
5300
|
+
Ok(Json(value))
|
|
5114
5301
|
}
|
|
5115
|
-
async fn
|
|
5116
|
-
|
|
5117
|
-
|
|
5302
|
+
async fn get_intent_root(
|
|
5303
|
+
State(state): State<AppState>,
|
|
5304
|
+
Extension(principal): Extension<Principal>,
|
|
5305
|
+
Path(id): Path<String>,
|
|
5306
|
+
Query(input): Query<ProposalScopeRequest>,
|
|
5307
|
+
) -> Result<Json<Value>, ApiError> {
|
|
5308
|
+
application_scope(
|
|
5309
|
+
&state,
|
|
5310
|
+
&principal.key_id,
|
|
5311
|
+
&input.application_id,
|
|
5312
|
+
"application:read",
|
|
5313
|
+
)?;
|
|
5314
|
+
let mut value = required_value(&state, INTENT_COLLECTION, &id, "intent not found")?;
|
|
5315
|
+
if !proposal_scope_matches(&value, &input.application_id, &input.environment) {
|
|
5316
|
+
return Err(ApiError(StatusCode::NOT_FOUND, "intent not found".into()));
|
|
5317
|
+
}
|
|
5318
|
+
if let Some(object) = value.as_object_mut() {
|
|
5319
|
+
object.remove("execution_result");
|
|
5320
|
+
}
|
|
5321
|
+
Ok(Json(value))
|
|
5322
|
+
}
|
|
5323
|
+
async fn internal_find_intent(
|
|
5324
|
+
State(state): State<AppState>,
|
|
5325
|
+
headers: HeaderMap,
|
|
5326
|
+
Query(input): Query<IntentHashRequest>,
|
|
5327
|
+
) -> Result<Json<Value>, ApiError> {
|
|
5328
|
+
require_intent_service(&headers)?;
|
|
5329
|
+
let value = state
|
|
5330
|
+
.db
|
|
5331
|
+
.list_collection(INTENT_COLLECTION)?
|
|
5332
|
+
.into_iter()
|
|
5333
|
+
.map(|row| row.value)
|
|
5334
|
+
.find(|value| {
|
|
5335
|
+
proposal_scope_matches(value, &input.application_id, &input.environment)
|
|
5336
|
+
&& value.get("intent_hash").and_then(Value::as_str)
|
|
5337
|
+
== Some(input.intent_hash.as_str())
|
|
5338
|
+
})
|
|
5339
|
+
.unwrap_or(Value::Null);
|
|
5340
|
+
Ok(Json(value))
|
|
5341
|
+
}
|
|
5342
|
+
async fn internal_create_intent(
|
|
5343
|
+
State(state): State<AppState>,
|
|
5344
|
+
headers: HeaderMap,
|
|
5345
|
+
Json(value): Json<Value>,
|
|
5346
|
+
) -> Result<(StatusCode, Json<Value>), ApiError> {
|
|
5347
|
+
require_intent_service(&headers)?;
|
|
5348
|
+
let id = value.get("id").and_then(Value::as_str).ok_or_else(|| {
|
|
5349
|
+
inspection_error(
|
|
5350
|
+
StatusCode::BAD_REQUEST,
|
|
5351
|
+
"invalid_intent",
|
|
5352
|
+
"intent id is required",
|
|
5353
|
+
)
|
|
5354
|
+
})?;
|
|
5355
|
+
insert_canonical(&state, INTENT_COLLECTION, id, value.clone())?;
|
|
5356
|
+
Ok((StatusCode::CREATED, Json(value)))
|
|
5357
|
+
}
|
|
5358
|
+
async fn internal_replace_intent(
|
|
5359
|
+
State(state): State<AppState>,
|
|
5360
|
+
headers: HeaderMap,
|
|
5361
|
+
Path(id): Path<String>,
|
|
5362
|
+
Json(value): Json<Value>,
|
|
5363
|
+
) -> Result<Json<Value>, ApiError> {
|
|
5364
|
+
require_intent_service(&headers)?;
|
|
5365
|
+
let _ = put_canonical(&state, INTENT_COLLECTION, &id, value.clone())?;
|
|
5366
|
+
Ok(Json(value))
|
|
5367
|
+
}
|
|
5368
|
+
async fn internal_create_intent_event(
|
|
5369
|
+
State(state): State<AppState>,
|
|
5370
|
+
headers: HeaderMap,
|
|
5371
|
+
Json(value): Json<Value>,
|
|
5372
|
+
) -> Result<(StatusCode, Json<Value>), ApiError> {
|
|
5373
|
+
require_intent_service(&headers)?;
|
|
5374
|
+
let id = value.get("id").and_then(Value::as_str).ok_or_else(|| {
|
|
5375
|
+
inspection_error(
|
|
5376
|
+
StatusCode::BAD_REQUEST,
|
|
5377
|
+
"invalid_intent_event",
|
|
5378
|
+
"event id is required",
|
|
5379
|
+
)
|
|
5380
|
+
})?;
|
|
5381
|
+
insert_canonical(&state, INTENT_EVENT_COLLECTION, id, value.clone())?;
|
|
5382
|
+
Ok((StatusCode::CREATED, Json(value)))
|
|
5118
5383
|
}
|
|
5119
|
-
async fn get_intent_root(State(state): State<AppState>, Extension(principal): Extension<Principal>, Path(id):Path<String>, Query(input):Query<ProposalScopeRequest>)->Result<Json<Value>,ApiError>{application_scope(&state,&principal.key_id,&input.application_id,"application:read")?;let mut value=required_value(&state,INTENT_COLLECTION,&id,"intent not found")?;if !proposal_scope_matches(&value,&input.application_id,&input.environment){return Err(ApiError(StatusCode::NOT_FOUND,"intent not found".into()));}if let Some(object)=value.as_object_mut(){object.remove("execution_result");}Ok(Json(value))}
|
|
5120
|
-
async fn internal_find_intent(State(state):State<AppState>,headers:HeaderMap,Query(input):Query<IntentHashRequest>)->Result<Json<Value>,ApiError>{require_intent_service(&headers)?;let value=state.db.list_collection(INTENT_COLLECTION)?.into_iter().map(|row|row.value).find(|value|proposal_scope_matches(value,&input.application_id,&input.environment)&&value.get("intent_hash").and_then(Value::as_str)==Some(input.intent_hash.as_str())).unwrap_or(Value::Null);Ok(Json(value))}
|
|
5121
|
-
async fn internal_create_intent(State(state):State<AppState>,headers:HeaderMap,Json(value):Json<Value>)->Result<(StatusCode,Json<Value>),ApiError>{require_intent_service(&headers)?;let id=value.get("id").and_then(Value::as_str).ok_or_else(||inspection_error(StatusCode::BAD_REQUEST,"invalid_intent","intent id is required"))?;insert_canonical(&state,INTENT_COLLECTION,id,value.clone())?;Ok((StatusCode::CREATED,Json(value)))}
|
|
5122
|
-
async fn internal_replace_intent(State(state):State<AppState>,headers:HeaderMap,Path(id):Path<String>,Json(value):Json<Value>)->Result<Json<Value>,ApiError>{require_intent_service(&headers)?;let _=put_canonical(&state,INTENT_COLLECTION,&id,value.clone())?;Ok(Json(value))}
|
|
5123
|
-
async fn internal_create_intent_event(State(state):State<AppState>,headers:HeaderMap,Json(value):Json<Value>)->Result<(StatusCode,Json<Value>),ApiError>{require_intent_service(&headers)?;let id=value.get("id").and_then(Value::as_str).ok_or_else(||inspection_error(StatusCode::BAD_REQUEST,"invalid_intent_event","event id is required"))?;insert_canonical(&state,INTENT_EVENT_COLLECTION,id,value.clone())?;Ok((StatusCode::CREATED,Json(value)))}
|
|
5124
5384
|
|
|
5125
|
-
fn proposal_contract_snapshot(
|
|
5126
|
-
revision
|
|
5127
|
-
|
|
5128
|
-
|
|
5129
|
-
|
|
5130
|
-
|
|
5131
|
-
|
|
5385
|
+
fn proposal_contract_snapshot(revision: &ApplicationRevision, environment: &str) -> Value {
|
|
5386
|
+
let contract_hash = revision
|
|
5387
|
+
.manifest
|
|
5388
|
+
.metadata
|
|
5389
|
+
.labels
|
|
5390
|
+
.get("feltdb.contract_hash")
|
|
5391
|
+
.unwrap_or(&revision.manifest_hash);
|
|
5392
|
+
let flow_hash = revision
|
|
5393
|
+
.manifest
|
|
5394
|
+
.metadata
|
|
5395
|
+
.labels
|
|
5396
|
+
.get("feltdb.flow_hash")
|
|
5397
|
+
.cloned();
|
|
5398
|
+
let dsl_version = revision
|
|
5399
|
+
.manifest
|
|
5400
|
+
.metadata
|
|
5401
|
+
.labels
|
|
5402
|
+
.get("feltdb.dsl_version")
|
|
5403
|
+
.and_then(|value| value.parse::<u32>().ok())
|
|
5404
|
+
.unwrap_or(1);
|
|
5132
5405
|
json!({
|
|
5133
5406
|
"application":{"application_id":revision.application_id,"environment":environment},
|
|
5134
5407
|
"contract_version":revision.revision_number,"contract_hash":contract_hash,"flow_hash":flow_hash,
|
|
@@ -5140,11 +5413,18 @@ fn proposal_contract_snapshot(
|
|
|
5140
5413
|
}
|
|
5141
5414
|
|
|
5142
5415
|
fn proposal_hash(value: &Value) -> String {
|
|
5143
|
-
format!(
|
|
5416
|
+
format!(
|
|
5417
|
+
"sha256:{:x}",
|
|
5418
|
+
Sha256::digest(serde_json::to_vec(value).unwrap_or_default())
|
|
5419
|
+
)
|
|
5144
5420
|
}
|
|
5145
5421
|
|
|
5146
5422
|
fn preview_ttl_seconds() -> i64 {
|
|
5147
|
-
std::env::var("FELTDB_PROPOSAL_PREVIEW_TTL_SECONDS")
|
|
5423
|
+
std::env::var("FELTDB_PROPOSAL_PREVIEW_TTL_SECONDS")
|
|
5424
|
+
.ok()
|
|
5425
|
+
.and_then(|value| value.parse().ok())
|
|
5426
|
+
.map(|value: i64| value.clamp(1, 86_400))
|
|
5427
|
+
.unwrap_or(3_600)
|
|
5148
5428
|
}
|
|
5149
5429
|
|
|
5150
5430
|
fn preview_artifact(state: &AppState, id: &str) -> Result<Value, ApiError> {
|
|
@@ -5153,7 +5433,10 @@ fn preview_artifact(state: &AppState, id: &str) -> Result<Value, ApiError> {
|
|
|
5153
5433
|
|
|
5154
5434
|
fn preview_available(value: &Value) -> bool {
|
|
5155
5435
|
value.get("status").and_then(Value::as_str) == Some("ready")
|
|
5156
|
-
&& value
|
|
5436
|
+
&& value
|
|
5437
|
+
.get("expires_at")
|
|
5438
|
+
.and_then(Value::as_i64)
|
|
5439
|
+
.is_some_and(|expires| expires > unix_seconds_i64())
|
|
5157
5440
|
}
|
|
5158
5441
|
|
|
5159
5442
|
fn proposal_event(
|
|
@@ -5190,15 +5473,33 @@ async fn create_proposal(
|
|
|
5190
5473
|
Json(input): Json<CreateProposalRequest>,
|
|
5191
5474
|
) -> Result<(StatusCode, Json<Value>), ApiError> {
|
|
5192
5475
|
if input.status.is_some() {
|
|
5193
|
-
return Err(inspection_error(
|
|
5476
|
+
return Err(inspection_error(
|
|
5477
|
+
StatusCode::BAD_REQUEST,
|
|
5478
|
+
"invalid_proposal",
|
|
5479
|
+
"proposal lifecycle state is server-controlled",
|
|
5480
|
+
));
|
|
5194
5481
|
}
|
|
5195
5482
|
if input.proposal_version.unwrap_or(1) != 1 {
|
|
5196
|
-
return Err(inspection_error(
|
|
5483
|
+
return Err(inspection_error(
|
|
5484
|
+
StatusCode::UNPROCESSABLE_ENTITY,
|
|
5485
|
+
"invalid_proposal",
|
|
5486
|
+
"proposal_version must be 1",
|
|
5487
|
+
));
|
|
5197
5488
|
}
|
|
5198
|
-
if input
|
|
5199
|
-
|
|
5489
|
+
if input
|
|
5490
|
+
.contract_diff
|
|
5491
|
+
.as_ref()
|
|
5492
|
+
.is_none_or(|value| !value.is_array())
|
|
5493
|
+
|| input
|
|
5494
|
+
.source_plan
|
|
5495
|
+
.as_ref()
|
|
5496
|
+
.is_none_or(|value| !value.get("files").is_some_and(Value::is_array))
|
|
5200
5497
|
{
|
|
5201
|
-
return Err(inspection_error(
|
|
5498
|
+
return Err(inspection_error(
|
|
5499
|
+
StatusCode::UNPROCESSABLE_ENTITY,
|
|
5500
|
+
"invalid_proposal",
|
|
5501
|
+
"contract_diff and source_plan.files are required arrays",
|
|
5502
|
+
));
|
|
5202
5503
|
}
|
|
5203
5504
|
let tenant = application_scope(
|
|
5204
5505
|
&state,
|
|
@@ -5243,19 +5544,41 @@ async fn create_proposal(
|
|
|
5243
5544
|
if input.metadata.get("created_from").and_then(Value::as_str) == Some("module-intent")
|
|
5244
5545
|
&& input.understanding.is_none()
|
|
5245
5546
|
{
|
|
5246
|
-
return Err(inspection_error(
|
|
5547
|
+
return Err(inspection_error(
|
|
5548
|
+
StatusCode::UNPROCESSABLE_ENTITY,
|
|
5549
|
+
"understanding_required",
|
|
5550
|
+
"module proposals require an evidence-backed understanding snapshot",
|
|
5551
|
+
));
|
|
5247
5552
|
}
|
|
5248
5553
|
if input.understanding.is_some()
|
|
5249
|
-
&& (input
|
|
5250
|
-
|
|
5554
|
+
&& (input
|
|
5555
|
+
.evidence_hash
|
|
5556
|
+
.as_deref()
|
|
5557
|
+
.is_none_or(|value| !value.starts_with("sha256:"))
|
|
5558
|
+
|| input
|
|
5559
|
+
.understanding_hash
|
|
5560
|
+
.as_deref()
|
|
5561
|
+
.is_none_or(|value| !value.starts_with("sha256:")))
|
|
5251
5562
|
{
|
|
5252
|
-
return Err(inspection_error(
|
|
5563
|
+
return Err(inspection_error(
|
|
5564
|
+
StatusCode::UNPROCESSABLE_ENTITY,
|
|
5565
|
+
"invalid_understanding",
|
|
5566
|
+
"understanding requires evidence_hash and understanding_hash fingerprints",
|
|
5567
|
+
));
|
|
5253
5568
|
}
|
|
5254
5569
|
if let Some(understanding) = input.understanding.as_ref() {
|
|
5255
|
-
if understanding.get("evidence_hash").and_then(Value::as_str)
|
|
5256
|
-
|
|
5570
|
+
if understanding.get("evidence_hash").and_then(Value::as_str)
|
|
5571
|
+
!= input.evidence_hash.as_deref()
|
|
5572
|
+
|| understanding
|
|
5573
|
+
.get("understanding_hash")
|
|
5574
|
+
.and_then(Value::as_str)
|
|
5575
|
+
!= input.understanding_hash.as_deref()
|
|
5257
5576
|
{
|
|
5258
|
-
return Err(inspection_error(
|
|
5577
|
+
return Err(inspection_error(
|
|
5578
|
+
StatusCode::UNPROCESSABLE_ENTITY,
|
|
5579
|
+
"invalid_understanding",
|
|
5580
|
+
"understanding fingerprints must match the Proposal fingerprints",
|
|
5581
|
+
));
|
|
5259
5582
|
}
|
|
5260
5583
|
}
|
|
5261
5584
|
let id = format!("p_{}", uuid::Uuid::new_v4().simple());
|
|
@@ -5291,35 +5614,92 @@ async fn list_proposals(
|
|
|
5291
5614
|
)?;
|
|
5292
5615
|
let limit = input.limit.unwrap_or(DEFAULT_INSPECTION_LIMIT);
|
|
5293
5616
|
if limit == 0 || limit > MAX_INSPECTION_LIMIT {
|
|
5294
|
-
return Err(inspection_error(
|
|
5617
|
+
return Err(inspection_error(
|
|
5618
|
+
StatusCode::UNPROCESSABLE_ENTITY,
|
|
5619
|
+
"invalid_pagination",
|
|
5620
|
+
format!("limit must be between 1 and {MAX_INSPECTION_LIMIT}"),
|
|
5621
|
+
));
|
|
5295
5622
|
}
|
|
5296
|
-
if input.status.as_ref().is_some_and(|status|
|
|
5297
|
-
|
|
5623
|
+
if input.status.as_ref().is_some_and(|status| {
|
|
5624
|
+
!matches!(
|
|
5625
|
+
status.as_str(),
|
|
5626
|
+
"proposed"
|
|
5627
|
+
| "validated"
|
|
5628
|
+
| "previewed"
|
|
5629
|
+
| "approved"
|
|
5630
|
+
| "applied"
|
|
5631
|
+
| "rejected"
|
|
5632
|
+
| "expired"
|
|
5633
|
+
)
|
|
5634
|
+
}) {
|
|
5635
|
+
return Err(inspection_error(
|
|
5636
|
+
StatusCode::UNPROCESSABLE_ENTITY,
|
|
5637
|
+
"unsupported_filter",
|
|
5638
|
+
"unsupported proposal status",
|
|
5639
|
+
));
|
|
5298
5640
|
}
|
|
5299
|
-
let filter_hash = format!(
|
|
5641
|
+
let filter_hash = format!(
|
|
5642
|
+
"{}:{}:{}",
|
|
5643
|
+
input.application_id,
|
|
5644
|
+
input.environment,
|
|
5645
|
+
input.status.as_deref().unwrap_or("*")
|
|
5646
|
+
);
|
|
5300
5647
|
let state_version = state.db.sequence()?;
|
|
5301
|
-
let mut after = input
|
|
5648
|
+
let mut after = input
|
|
5649
|
+
.cursor
|
|
5650
|
+
.as_deref()
|
|
5651
|
+
.map(|cursor| {
|
|
5652
|
+
parse_inspection_cursor(cursor, PROPOSAL_COLLECTION, state_version, &filter_hash)
|
|
5653
|
+
})
|
|
5654
|
+
.transpose()?;
|
|
5302
5655
|
let mut accepted = Vec::new();
|
|
5303
5656
|
let mut exhausted = false;
|
|
5304
5657
|
while accepted.len() <= limit && !exhausted {
|
|
5305
|
-
let page = state
|
|
5306
|
-
|
|
5307
|
-
|
|
5658
|
+
let page = state
|
|
5659
|
+
.db
|
|
5660
|
+
.list_collection_page(PROPOSAL_COLLECTION, after.as_deref(), 256)?;
|
|
5661
|
+
if page.len() < 256 {
|
|
5662
|
+
exhausted = true;
|
|
5663
|
+
}
|
|
5664
|
+
if page.is_empty() {
|
|
5665
|
+
break;
|
|
5666
|
+
}
|
|
5308
5667
|
for row in page {
|
|
5309
5668
|
after = Some(row.key.clone());
|
|
5310
|
-
if !proposal_scope_matches(&row.value, &input.application_id, &input.environment) {
|
|
5311
|
-
|
|
5669
|
+
if !proposal_scope_matches(&row.value, &input.application_id, &input.environment) {
|
|
5670
|
+
continue;
|
|
5671
|
+
}
|
|
5672
|
+
if input.status.as_ref().is_some_and(|status| {
|
|
5673
|
+
row.value.get("status").and_then(Value::as_str) != Some(status)
|
|
5674
|
+
}) {
|
|
5675
|
+
continue;
|
|
5676
|
+
}
|
|
5312
5677
|
let mut value = row.value;
|
|
5313
5678
|
redact_inspection_value(&mut value);
|
|
5314
5679
|
accepted.push((row.key, value));
|
|
5315
|
-
if accepted.len() > limit {
|
|
5680
|
+
if accepted.len() > limit {
|
|
5681
|
+
break;
|
|
5682
|
+
}
|
|
5316
5683
|
}
|
|
5317
5684
|
}
|
|
5318
5685
|
let has_more = accepted.len() > limit;
|
|
5319
|
-
let last_key = accepted
|
|
5320
|
-
|
|
5321
|
-
|
|
5322
|
-
|
|
5686
|
+
let last_key = accepted
|
|
5687
|
+
.get(limit.saturating_sub(1))
|
|
5688
|
+
.map(|entry| entry.0.clone());
|
|
5689
|
+
let proposals = accepted
|
|
5690
|
+
.into_iter()
|
|
5691
|
+
.take(limit)
|
|
5692
|
+
.map(|entry| entry.1)
|
|
5693
|
+
.collect::<Vec<_>>();
|
|
5694
|
+
let next_cursor = if has_more {
|
|
5695
|
+
last_key
|
|
5696
|
+
.map(|key| inspection_cursor(PROPOSAL_COLLECTION, state_version, &key, &filter_hash))
|
|
5697
|
+
} else {
|
|
5698
|
+
None
|
|
5699
|
+
};
|
|
5700
|
+
Ok(Json(
|
|
5701
|
+
json!({"proposals":proposals,"pagination":{"limit":limit,"nextCursor":next_cursor},"state_version":state_version}),
|
|
5702
|
+
))
|
|
5323
5703
|
}
|
|
5324
5704
|
async fn get_proposal(
|
|
5325
5705
|
State(state): State<AppState>,
|
|
@@ -5361,8 +5741,19 @@ async fn proposal_history(
|
|
|
5361
5741
|
.map(|row| row.value)
|
|
5362
5742
|
.filter(|value| value.get("proposal_id").and_then(Value::as_str) == Some(id.as_str()))
|
|
5363
5743
|
.collect();
|
|
5364
|
-
events.sort_by(|left, right|
|
|
5365
|
-
|
|
5744
|
+
events.sort_by(|left, right| {
|
|
5745
|
+
left.get("event_sequence")
|
|
5746
|
+
.and_then(Value::as_u64)
|
|
5747
|
+
.cmp(&right.get("event_sequence").and_then(Value::as_u64))
|
|
5748
|
+
.then_with(|| {
|
|
5749
|
+
left.get("id")
|
|
5750
|
+
.and_then(Value::as_str)
|
|
5751
|
+
.cmp(&right.get("id").and_then(Value::as_str))
|
|
5752
|
+
})
|
|
5753
|
+
});
|
|
5754
|
+
for event in &mut events {
|
|
5755
|
+
redact_inspection_value(event);
|
|
5756
|
+
}
|
|
5366
5757
|
Ok(Json(json!({"events":events})))
|
|
5367
5758
|
}
|
|
5368
5759
|
fn move_proposal(
|
|
@@ -5412,7 +5803,10 @@ fn move_proposal(
|
|
|
5412
5803
|
}
|
|
5413
5804
|
|
|
5414
5805
|
fn proposal_is_expired(value: &Value) -> bool {
|
|
5415
|
-
value
|
|
5806
|
+
value
|
|
5807
|
+
.get("expires_at")
|
|
5808
|
+
.and_then(|expires| expires.as_i64().or_else(|| expires.as_str()?.parse().ok()))
|
|
5809
|
+
.is_some_and(|expires| expires <= unix_seconds_i64())
|
|
5416
5810
|
}
|
|
5417
5811
|
|
|
5418
5812
|
fn proposal_readiness_value(
|
|
@@ -5421,26 +5815,89 @@ fn proposal_readiness_value(
|
|
|
5421
5815
|
value: &Value,
|
|
5422
5816
|
current_evidence_hash: Option<&str>,
|
|
5423
5817
|
) -> Result<Value, ApiError> {
|
|
5424
|
-
let application_id = value
|
|
5425
|
-
|
|
5426
|
-
|
|
5427
|
-
|
|
5428
|
-
let
|
|
5429
|
-
|
|
5430
|
-
|
|
5431
|
-
|
|
5818
|
+
let application_id = value
|
|
5819
|
+
.get("application_id")
|
|
5820
|
+
.and_then(Value::as_str)
|
|
5821
|
+
.unwrap_or("");
|
|
5822
|
+
let environment = value
|
|
5823
|
+
.get("environment")
|
|
5824
|
+
.and_then(Value::as_str)
|
|
5825
|
+
.unwrap_or("production");
|
|
5826
|
+
let revision_id = state
|
|
5827
|
+
.applications
|
|
5828
|
+
.pointers(application_id)
|
|
5829
|
+
.get(environment)
|
|
5830
|
+
.cloned()
|
|
5831
|
+
.ok_or_else(|| {
|
|
5832
|
+
inspection_error(
|
|
5833
|
+
StatusCode::NOT_FOUND,
|
|
5834
|
+
"application_not_found",
|
|
5835
|
+
"active application contract not found",
|
|
5836
|
+
)
|
|
5837
|
+
})?;
|
|
5838
|
+
let revision = state
|
|
5839
|
+
.applications
|
|
5840
|
+
.revision(tenant, application_id, &revision_id)
|
|
5841
|
+
.ok_or_else(|| {
|
|
5842
|
+
inspection_error(
|
|
5843
|
+
StatusCode::NOT_FOUND,
|
|
5844
|
+
"application_not_found",
|
|
5845
|
+
"active application revision not found",
|
|
5846
|
+
)
|
|
5847
|
+
})?;
|
|
5848
|
+
let current_contract_hash = revision
|
|
5849
|
+
.manifest
|
|
5850
|
+
.metadata
|
|
5851
|
+
.labels
|
|
5852
|
+
.get("feltdb.contract_hash")
|
|
5853
|
+
.unwrap_or(&revision.manifest_hash)
|
|
5854
|
+
.clone();
|
|
5855
|
+
let current_flow_hash = revision
|
|
5856
|
+
.manifest
|
|
5857
|
+
.metadata
|
|
5858
|
+
.labels
|
|
5859
|
+
.get("feltdb.flow_hash")
|
|
5860
|
+
.cloned();
|
|
5861
|
+
let proposal_contract_hash = value
|
|
5862
|
+
.get("base_contract_hash")
|
|
5863
|
+
.and_then(Value::as_str)
|
|
5864
|
+
.unwrap_or("");
|
|
5865
|
+
let proposal_flow_hash = value
|
|
5866
|
+
.get("base_flow_hash")
|
|
5867
|
+
.and_then(Value::as_str)
|
|
5868
|
+
.unwrap_or("");
|
|
5432
5869
|
let current_modules = revision.manifest.modules.iter().filter_map(|module| Some(json!({"id":module.get("id")?,"provider":module.get("provider")?,"version":module.get("version")?}))).collect::<Vec<_>>();
|
|
5433
5870
|
let contract_changed = current_contract_hash != proposal_contract_hash;
|
|
5434
|
-
let flow_changed = current_flow_hash
|
|
5435
|
-
|
|
5436
|
-
|
|
5871
|
+
let flow_changed = current_flow_hash
|
|
5872
|
+
.as_deref()
|
|
5873
|
+
.is_some_and(|hash| hash != proposal_flow_hash);
|
|
5874
|
+
let modules_changed = value
|
|
5875
|
+
.get("base_module_versions")
|
|
5876
|
+
.and_then(Value::as_array)
|
|
5877
|
+
.is_some_and(|modules| modules != ¤t_modules);
|
|
5878
|
+
let evidence_changed = current_evidence_hash.is_some_and(|current| {
|
|
5879
|
+
value
|
|
5880
|
+
.get("evidence_hash")
|
|
5881
|
+
.and_then(Value::as_str)
|
|
5882
|
+
.is_some_and(|base| base != current)
|
|
5883
|
+
});
|
|
5437
5884
|
let expired = proposal_is_expired(value);
|
|
5438
5885
|
let mut blockers = Vec::new();
|
|
5439
|
-
if contract_changed {
|
|
5440
|
-
|
|
5441
|
-
|
|
5442
|
-
if
|
|
5443
|
-
|
|
5886
|
+
if contract_changed {
|
|
5887
|
+
blockers.push("contract_changed");
|
|
5888
|
+
}
|
|
5889
|
+
if flow_changed {
|
|
5890
|
+
blockers.push("flow_changed");
|
|
5891
|
+
}
|
|
5892
|
+
if modules_changed {
|
|
5893
|
+
blockers.push("module_versions_changed");
|
|
5894
|
+
}
|
|
5895
|
+
if evidence_changed {
|
|
5896
|
+
blockers.push("evidence_changed");
|
|
5897
|
+
}
|
|
5898
|
+
if expired {
|
|
5899
|
+
blockers.push("expired");
|
|
5900
|
+
}
|
|
5444
5901
|
let stale = contract_changed || flow_changed || modules_changed || evidence_changed;
|
|
5445
5902
|
Ok(json!({
|
|
5446
5903
|
"status":value.get("status"), "readiness":if blockers.is_empty(){"ready"}else{"blocked"},
|
|
@@ -5449,41 +5906,163 @@ fn proposal_readiness_value(
|
|
|
5449
5906
|
"proposal_flow_hash":proposal_flow_hash, "stale":stale, "expires_at":value.get("expires_at")
|
|
5450
5907
|
}))
|
|
5451
5908
|
}
|
|
5452
|
-
|
|
5453
|
-
async fn proposal_status(
|
|
5909
|
+
|
|
5910
|
+
async fn proposal_status(
|
|
5911
|
+
State(state): State<AppState>,
|
|
5912
|
+
Extension(principal): Extension<Principal>,
|
|
5913
|
+
Path(id): Path<String>,
|
|
5914
|
+
Query(input): Query<ProposalScopeRequest>,
|
|
5915
|
+
) -> Result<Json<Value>, ApiError> {
|
|
5916
|
+
let tenant = application_scope(
|
|
5917
|
+
&state,
|
|
5918
|
+
&principal.key_id,
|
|
5919
|
+
&input.application_id,
|
|
5920
|
+
"application:read",
|
|
5921
|
+
)?;
|
|
5922
|
+
let mut value = proposal_value(&state, &id)?;
|
|
5923
|
+
if !proposal_scope_matches(&value, &input.application_id, &input.environment) {
|
|
5924
|
+
return Err(inspection_error(
|
|
5925
|
+
StatusCode::NOT_FOUND,
|
|
5926
|
+
"proposal_not_found",
|
|
5927
|
+
"proposal not found",
|
|
5928
|
+
));
|
|
5929
|
+
}
|
|
5930
|
+
Ok(Json(proposal_readiness_value(
|
|
5931
|
+
&state,
|
|
5932
|
+
&tenant,
|
|
5933
|
+
&value,
|
|
5934
|
+
input.current_evidence_hash.as_deref(),
|
|
5935
|
+
)?))
|
|
5936
|
+
}
|
|
5937
|
+
async fn record_proposal_convergence(
|
|
5938
|
+
State(state): State<AppState>,
|
|
5939
|
+
Extension(principal): Extension<Principal>,
|
|
5940
|
+
Path(id): Path<String>,
|
|
5941
|
+
Json(mut input): Json<Value>,
|
|
5942
|
+
) -> Result<(StatusCode, Json<Value>), ApiError> {
|
|
5943
|
+
let application_id = input
|
|
5944
|
+
.get("application_id")
|
|
5945
|
+
.and_then(Value::as_str)
|
|
5946
|
+
.ok_or_else(|| {
|
|
5947
|
+
inspection_error(
|
|
5948
|
+
StatusCode::BAD_REQUEST,
|
|
5949
|
+
"invalid_convergence",
|
|
5950
|
+
"application_id is required",
|
|
5951
|
+
)
|
|
5952
|
+
})?
|
|
5953
|
+
.to_string();
|
|
5954
|
+
let environment = input
|
|
5955
|
+
.get("environment")
|
|
5956
|
+
.and_then(Value::as_str)
|
|
5957
|
+
.unwrap_or("production")
|
|
5958
|
+
.to_string();
|
|
5959
|
+
application_scope(
|
|
5960
|
+
&state,
|
|
5961
|
+
&principal.key_id,
|
|
5962
|
+
&application_id,
|
|
5963
|
+
"application:write",
|
|
5964
|
+
)?;
|
|
5965
|
+
let proposal = proposal_value(&state, &id)?;
|
|
5966
|
+
if !proposal_scope_matches(&proposal, &application_id, &environment) {
|
|
5967
|
+
return Err(inspection_error(
|
|
5968
|
+
StatusCode::NOT_FOUND,
|
|
5969
|
+
"proposal_not_found",
|
|
5970
|
+
"proposal not found",
|
|
5971
|
+
));
|
|
5972
|
+
}
|
|
5973
|
+
if proposal.get("status").and_then(Value::as_str) != Some("applied") {
|
|
5974
|
+
return Err(inspection_error(
|
|
5975
|
+
StatusCode::CONFLICT,
|
|
5976
|
+
"proposal_not_applied",
|
|
5977
|
+
"only an applied Proposal can record convergence",
|
|
5978
|
+
));
|
|
5979
|
+
}
|
|
5980
|
+
if input.get("proposal_id").and_then(Value::as_str) != Some(id.as_str()) {
|
|
5981
|
+
return Err(inspection_error(
|
|
5982
|
+
StatusCode::UNPROCESSABLE_ENTITY,
|
|
5983
|
+
"invalid_convergence",
|
|
5984
|
+
"proposal_id must match the route",
|
|
5985
|
+
));
|
|
5986
|
+
}
|
|
5987
|
+
let valid_status = input
|
|
5988
|
+
.get("status")
|
|
5989
|
+
.and_then(Value::as_str)
|
|
5990
|
+
.is_some_and(|value| {
|
|
5991
|
+
matches!(
|
|
5992
|
+
value,
|
|
5993
|
+
"pending"
|
|
5994
|
+
| "checking"
|
|
5995
|
+
| "converged"
|
|
5996
|
+
| "partially_converged"
|
|
5997
|
+
| "diverged"
|
|
5998
|
+
| "blocked"
|
|
5999
|
+
| "unverified"
|
|
6000
|
+
)
|
|
6001
|
+
});
|
|
6002
|
+
if !valid_status
|
|
6003
|
+
|| input.get("findings").is_none_or(|value| !value.is_array())
|
|
6004
|
+
|| input
|
|
6005
|
+
.get("evidence_hash")
|
|
6006
|
+
.and_then(Value::as_str)
|
|
6007
|
+
.is_none_or(|value| !value.starts_with("sha256:"))
|
|
6008
|
+
{
|
|
6009
|
+
return Err(inspection_error(
|
|
6010
|
+
StatusCode::UNPROCESSABLE_ENTITY,
|
|
6011
|
+
"invalid_convergence",
|
|
6012
|
+
"status, findings, and evidence_hash are required",
|
|
6013
|
+
));
|
|
6014
|
+
}
|
|
6015
|
+
let convergence_id = format!("cv_{}", uuid::Uuid::new_v4().simple());
|
|
6016
|
+
input["id"] = json!(convergence_id);
|
|
6017
|
+
input["created_at"] = json!(unix_seconds_i64());
|
|
6018
|
+
input["verifier_version"] = json!(1);
|
|
6019
|
+
redact_inspection_value(&mut input);
|
|
6020
|
+
insert_canonical(
|
|
6021
|
+
&state,
|
|
6022
|
+
CONVERGENCE_COLLECTION,
|
|
6023
|
+
&convergence_id,
|
|
6024
|
+
input.clone(),
|
|
6025
|
+
)?;
|
|
6026
|
+
Ok((StatusCode::CREATED, Json(input)))
|
|
6027
|
+
}
|
|
6028
|
+
async fn proposal_convergence(
|
|
5454
6029
|
State(state): State<AppState>,
|
|
5455
6030
|
Extension(principal): Extension<Principal>,
|
|
5456
6031
|
Path(id): Path<String>,
|
|
5457
6032
|
Query(input): Query<ProposalScopeRequest>,
|
|
5458
6033
|
) -> Result<Json<Value>, ApiError> {
|
|
5459
|
-
|
|
5460
|
-
|
|
5461
|
-
|
|
5462
|
-
|
|
6034
|
+
application_scope(
|
|
6035
|
+
&state,
|
|
6036
|
+
&principal.key_id,
|
|
6037
|
+
&input.application_id,
|
|
6038
|
+
"application:read",
|
|
6039
|
+
)?;
|
|
6040
|
+
let proposal = proposal_value(&state, &id)?;
|
|
6041
|
+
if !proposal_scope_matches(&proposal, &input.application_id, &input.environment) {
|
|
6042
|
+
return Err(inspection_error(
|
|
6043
|
+
StatusCode::NOT_FOUND,
|
|
6044
|
+
"proposal_not_found",
|
|
6045
|
+
"proposal not found",
|
|
6046
|
+
));
|
|
5463
6047
|
}
|
|
5464
|
-
|
|
5465
|
-
|
|
5466
|
-
|
|
5467
|
-
|
|
5468
|
-
|
|
5469
|
-
|
|
5470
|
-
|
|
5471
|
-
|
|
5472
|
-
|
|
5473
|
-
|
|
5474
|
-
|
|
5475
|
-
|
|
5476
|
-
|
|
5477
|
-
|
|
5478
|
-
|
|
5479
|
-
}
|
|
5480
|
-
|
|
5481
|
-
|
|
5482
|
-
) -> Result<Json<Value>, ApiError> {
|
|
5483
|
-
application_scope(&state,&principal.key_id,&input.application_id,"application:read")?;let proposal=proposal_value(&state,&id)?;
|
|
5484
|
-
if !proposal_scope_matches(&proposal,&input.application_id,&input.environment){return Err(inspection_error(StatusCode::NOT_FOUND,"proposal_not_found","proposal not found"));}
|
|
5485
|
-
let mut values=state.db.list_collection(CONVERGENCE_COLLECTION)?.into_iter().map(|row|row.value).filter(|value|value.get("proposal_id").and_then(Value::as_str)==Some(id.as_str())&&proposal_scope_matches(value,&input.application_id,&input.environment)).collect::<Vec<_>>();
|
|
5486
|
-
values.sort_by_key(|value|value.get("created_at").and_then(Value::as_i64).unwrap_or_default());values.reverse();Ok(Json(json!({"convergence":values})))
|
|
6048
|
+
let mut values = state
|
|
6049
|
+
.db
|
|
6050
|
+
.list_collection(CONVERGENCE_COLLECTION)?
|
|
6051
|
+
.into_iter()
|
|
6052
|
+
.map(|row| row.value)
|
|
6053
|
+
.filter(|value| {
|
|
6054
|
+
value.get("proposal_id").and_then(Value::as_str) == Some(id.as_str())
|
|
6055
|
+
&& proposal_scope_matches(value, &input.application_id, &input.environment)
|
|
6056
|
+
})
|
|
6057
|
+
.collect::<Vec<_>>();
|
|
6058
|
+
values.sort_by_key(|value| {
|
|
6059
|
+
value
|
|
6060
|
+
.get("created_at")
|
|
6061
|
+
.and_then(Value::as_i64)
|
|
6062
|
+
.unwrap_or_default()
|
|
6063
|
+
});
|
|
6064
|
+
values.reverse();
|
|
6065
|
+
Ok(Json(json!({"convergence":values})))
|
|
5487
6066
|
}
|
|
5488
6067
|
async fn validate_proposal(
|
|
5489
6068
|
State(state): State<AppState>,
|
|
@@ -5620,7 +6199,11 @@ async fn validate_proposal(
|
|
|
5620
6199
|
value["proposed_contract"] = serde_json::to_value(&proposed.manifest)
|
|
5621
6200
|
.map_err(|e| ApiError(StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
|
5622
6201
|
}
|
|
5623
|
-
if !value
|
|
6202
|
+
if !value
|
|
6203
|
+
.pointer("/source_plan/files")
|
|
6204
|
+
.and_then(Value::as_array)
|
|
6205
|
+
.is_some_and(|files| !files.is_empty())
|
|
6206
|
+
{
|
|
5624
6207
|
value["source_plan"] = json!({"files":[{"path":"feltdb.flow","operation":"modify"},{"path":"src/feltdb/generated-contract.ts","operation":"modify"}]});
|
|
5625
6208
|
}
|
|
5626
6209
|
Ok(Json(move_proposal(
|
|
@@ -5647,7 +6230,8 @@ async fn preview_proposal(
|
|
|
5647
6230
|
if !proposal_scope_matches(&value, &input.application_id, &input.environment) {
|
|
5648
6231
|
return Err(ApiError(StatusCode::NOT_FOUND, "proposal not found".into()));
|
|
5649
6232
|
}
|
|
5650
|
-
let replacing_expired_preview =
|
|
6233
|
+
let replacing_expired_preview =
|
|
6234
|
+
value.get("status").and_then(Value::as_str) == Some("previewed");
|
|
5651
6235
|
if replacing_expired_preview {
|
|
5652
6236
|
if let Some(preview_id) = value.get("preview_id").and_then(Value::as_str) {
|
|
5653
6237
|
let mut artifact = preview_artifact(&state, preview_id)?;
|
|
@@ -5655,42 +6239,131 @@ async fn preview_proposal(
|
|
|
5655
6239
|
let mut proposal = value.clone();
|
|
5656
6240
|
redact_inspection_value(&mut proposal);
|
|
5657
6241
|
redact_inspection_value(&mut artifact);
|
|
5658
|
-
if let Some(object) = artifact.as_object_mut() {
|
|
6242
|
+
if let Some(object) = artifact.as_object_mut() {
|
|
6243
|
+
object.remove("namespace");
|
|
6244
|
+
}
|
|
5659
6245
|
artifact["proposal"] = proposal;
|
|
5660
6246
|
return Ok(Json(artifact));
|
|
5661
6247
|
}
|
|
5662
6248
|
}
|
|
5663
6249
|
}
|
|
5664
|
-
if !replacing_expired_preview
|
|
5665
|
-
|
|
6250
|
+
if !replacing_expired_preview
|
|
6251
|
+
&& value.get("status").and_then(Value::as_str) != Some("validated")
|
|
6252
|
+
{
|
|
6253
|
+
return Err(inspection_error(
|
|
6254
|
+
StatusCode::CONFLICT,
|
|
6255
|
+
"invalid_proposal_transition",
|
|
6256
|
+
"only a validated proposal can be previewed",
|
|
6257
|
+
));
|
|
5666
6258
|
}
|
|
5667
|
-
let proposed_manifest: ApplicationManifest =
|
|
5668
|
-
|
|
5669
|
-
|
|
6259
|
+
let proposed_manifest: ApplicationManifest =
|
|
6260
|
+
serde_json::from_value(value.get("proposed_contract").cloned().ok_or_else(|| {
|
|
6261
|
+
inspection_error(
|
|
6262
|
+
StatusCode::UNPROCESSABLE_ENTITY,
|
|
6263
|
+
"missing_required_semantics",
|
|
6264
|
+
"validated proposal has no proposed contract snapshot",
|
|
6265
|
+
)
|
|
6266
|
+
})?)
|
|
6267
|
+
.map_err(|error| {
|
|
6268
|
+
inspection_error(
|
|
6269
|
+
StatusCode::UNPROCESSABLE_ENTITY,
|
|
6270
|
+
"invalid_proposal",
|
|
6271
|
+
error.to_string(),
|
|
6272
|
+
)
|
|
6273
|
+
})?;
|
|
6274
|
+
let current_revision_id = state
|
|
6275
|
+
.applications
|
|
6276
|
+
.pointers(&input.application_id)
|
|
6277
|
+
.get(&input.environment)
|
|
6278
|
+
.cloned()
|
|
6279
|
+
.ok_or_else(|| {
|
|
6280
|
+
inspection_error(
|
|
6281
|
+
StatusCode::NOT_FOUND,
|
|
6282
|
+
"application_not_found",
|
|
6283
|
+
"active application contract not found",
|
|
6284
|
+
)
|
|
6285
|
+
})?;
|
|
6286
|
+
let current_revision = state
|
|
6287
|
+
.applications
|
|
6288
|
+
.revision(&tenant, &input.application_id, ¤t_revision_id)
|
|
6289
|
+
.ok_or_else(|| {
|
|
6290
|
+
inspection_error(
|
|
6291
|
+
StatusCode::NOT_FOUND,
|
|
6292
|
+
"application_not_found",
|
|
6293
|
+
"active application revision not found",
|
|
6294
|
+
)
|
|
6295
|
+
})?;
|
|
5670
6296
|
let readiness = proposal_readiness_value(&state, &tenant, &value, None)?;
|
|
5671
6297
|
if readiness.get("readiness").and_then(Value::as_str) != Some("ready") {
|
|
5672
|
-
return Err(ApiError::structured(
|
|
6298
|
+
return Err(ApiError::structured(
|
|
6299
|
+
StatusCode::CONFLICT,
|
|
6300
|
+
json!({"code":"preview_not_ready","message":"proposal cannot be previewed","readiness":readiness}),
|
|
6301
|
+
));
|
|
5673
6302
|
}
|
|
5674
6303
|
let preview_id = format!("pv_{}", uuid::Uuid::new_v4().simple());
|
|
5675
6304
|
let preview_environment = format!("preview/proposal/{preview_id}");
|
|
5676
|
-
let proposed_manifest_hash = manifest_hash(&proposed_manifest).map_err(|error|
|
|
5677
|
-
|
|
5678
|
-
|
|
5679
|
-
|
|
5680
|
-
|
|
5681
|
-
|
|
6305
|
+
let proposed_manifest_hash = manifest_hash(&proposed_manifest).map_err(|error| {
|
|
6306
|
+
inspection_error(
|
|
6307
|
+
StatusCode::UNPROCESSABLE_ENTITY,
|
|
6308
|
+
"preview_contract_invalid",
|
|
6309
|
+
error,
|
|
6310
|
+
)
|
|
6311
|
+
})?;
|
|
6312
|
+
let proposed_revision = ApplicationRevision {
|
|
6313
|
+
revision_id: format!("proposal://{id}"),
|
|
6314
|
+
application_id: input.application_id.clone(),
|
|
6315
|
+
tenant_id: tenant,
|
|
6316
|
+
revision_number: value
|
|
6317
|
+
.get("base_contract_version")
|
|
6318
|
+
.and_then(Value::as_u64)
|
|
6319
|
+
.unwrap_or(current_revision.revision_number)
|
|
6320
|
+
+ 1,
|
|
6321
|
+
parent_revision_id: Some(current_revision.revision_id.clone()),
|
|
6322
|
+
manifest_hash: proposed_manifest_hash,
|
|
6323
|
+
manifest: proposed_manifest,
|
|
6324
|
+
created_by: principal.key_id.clone(),
|
|
6325
|
+
created_at: unix_seconds_i64() as u64,
|
|
6326
|
+
status: RevisionStatus::Committed,
|
|
6327
|
+
};
|
|
6328
|
+
let runtime = resolve_runtime(
|
|
6329
|
+
&proposed_revision,
|
|
6330
|
+
&preview_environment,
|
|
6331
|
+
&RuntimeInventory::default(),
|
|
6332
|
+
)
|
|
6333
|
+
.map_err(|report| {
|
|
6334
|
+
ApiError::structured(
|
|
6335
|
+
StatusCode::UNPROCESSABLE_ENTITY,
|
|
6336
|
+
json!({"code":"preview_contract_invalid","validation":report}),
|
|
6337
|
+
)
|
|
6338
|
+
})?;
|
|
5682
6339
|
let current_contract = proposal_contract_snapshot(¤t_revision, &input.environment);
|
|
5683
6340
|
let mut proposed_contract = proposal_contract_snapshot(&proposed_revision, &input.environment);
|
|
5684
6341
|
proposed_contract["contract_hash"] = json!(proposal_hash(&proposed_contract));
|
|
5685
|
-
proposed_contract["flow_hash"] = json!(format!(
|
|
6342
|
+
proposed_contract["flow_hash"] = json!(format!(
|
|
6343
|
+
"sha256:{:x}",
|
|
6344
|
+
Sha256::digest(
|
|
6345
|
+
value
|
|
6346
|
+
.get("proposed_flow")
|
|
6347
|
+
.and_then(Value::as_str)
|
|
6348
|
+
.unwrap_or("")
|
|
6349
|
+
.replace("\r\n", "\n")
|
|
6350
|
+
.as_bytes()
|
|
6351
|
+
)
|
|
6352
|
+
));
|
|
5686
6353
|
let namespace = runtime.environment.state_namespace.clone();
|
|
5687
6354
|
let mut seeded_records = Vec::new();
|
|
5688
6355
|
for collection in &proposed_revision.manifest.collections {
|
|
5689
|
-
if collection.name.starts_with("_feltdb") {
|
|
6356
|
+
if collection.name.starts_with("_feltdb") {
|
|
6357
|
+
continue;
|
|
6358
|
+
}
|
|
5690
6359
|
let record_id = format!("preview_{}", collection.name.to_ascii_lowercase());
|
|
5691
6360
|
let record = json!({"id":record_id,"_preview_data":true,"_preview_source":"synthetic","label":format!("Synthetic {} preview",collection.name)});
|
|
5692
|
-
state
|
|
5693
|
-
|
|
6361
|
+
state
|
|
6362
|
+
.db
|
|
6363
|
+
.capability(&format!("{}:{}", namespace, collection.name))
|
|
6364
|
+
.insert_with_key(&record_id, record)?;
|
|
6365
|
+
seeded_records
|
|
6366
|
+
.push(json!({"collection":collection.name,"id":record_id,"preview_data":true}));
|
|
5694
6367
|
}
|
|
5695
6368
|
let now = unix_seconds_i64();
|
|
5696
6369
|
let mut artifact = json!({
|
|
@@ -5706,7 +6379,15 @@ async fn preview_proposal(
|
|
|
5706
6379
|
"seeded_records":seeded_records,"data_classification":"synthetic_preview","external_side_effects":"simulated","readiness":readiness
|
|
5707
6380
|
});
|
|
5708
6381
|
redact_inspection_value(&mut artifact);
|
|
5709
|
-
insert_canonical(
|
|
6382
|
+
insert_canonical(
|
|
6383
|
+
&state,
|
|
6384
|
+
PROPOSAL_PREVIEW_COLLECTION,
|
|
6385
|
+
artifact
|
|
6386
|
+
.get("preview_id")
|
|
6387
|
+
.and_then(Value::as_str)
|
|
6388
|
+
.unwrap_or(""),
|
|
6389
|
+
artifact.clone(),
|
|
6390
|
+
)?;
|
|
5710
6391
|
value["preview_id"] = artifact.get("preview_id").cloned().unwrap_or(Value::Null);
|
|
5711
6392
|
value["preview_expires_at"] = artifact.get("expires_at").cloned().unwrap_or(Value::Null);
|
|
5712
6393
|
let mut stored_proposal = if replacing_expired_preview {
|
|
@@ -5716,76 +6397,209 @@ async fn preview_proposal(
|
|
|
5716
6397
|
move_proposal(&state, &id, value, "previewed", &principal.key_id)?
|
|
5717
6398
|
};
|
|
5718
6399
|
redact_inspection_value(&mut stored_proposal);
|
|
5719
|
-
if let Some(object) = artifact.as_object_mut() {
|
|
6400
|
+
if let Some(object) = artifact.as_object_mut() {
|
|
6401
|
+
object.remove("namespace");
|
|
6402
|
+
}
|
|
5720
6403
|
artifact["proposal"] = stored_proposal;
|
|
5721
6404
|
Ok(Json(artifact))
|
|
5722
6405
|
}
|
|
5723
6406
|
|
|
5724
6407
|
async fn get_proposal_preview(
|
|
5725
|
-
State(state): State<AppState>,
|
|
6408
|
+
State(state): State<AppState>,
|
|
6409
|
+
Extension(principal): Extension<Principal>,
|
|
6410
|
+
Path(id): Path<String>,
|
|
5726
6411
|
Query(input): Query<ProposalScopeRequest>,
|
|
5727
6412
|
) -> Result<Json<Value>, ApiError> {
|
|
5728
|
-
let tenant = application_scope(
|
|
6413
|
+
let tenant = application_scope(
|
|
6414
|
+
&state,
|
|
6415
|
+
&principal.key_id,
|
|
6416
|
+
&input.application_id,
|
|
6417
|
+
"application:read",
|
|
6418
|
+
)?;
|
|
5729
6419
|
let mut artifact = preview_artifact(&state, &id)?;
|
|
5730
6420
|
if !proposal_scope_matches(&artifact, &input.application_id, &input.environment) {
|
|
5731
|
-
return Err(inspection_error(
|
|
6421
|
+
return Err(inspection_error(
|
|
6422
|
+
StatusCode::NOT_FOUND,
|
|
6423
|
+
"preview_not_found",
|
|
6424
|
+
"preview not found",
|
|
6425
|
+
));
|
|
5732
6426
|
}
|
|
5733
6427
|
if !preview_available(&artifact) {
|
|
5734
|
-
return Err(inspection_error(
|
|
6428
|
+
return Err(inspection_error(
|
|
6429
|
+
StatusCode::GONE,
|
|
6430
|
+
"preview_expired",
|
|
6431
|
+
"preview artifact has expired",
|
|
6432
|
+
));
|
|
5735
6433
|
}
|
|
5736
|
-
let proposal = proposal_value(
|
|
6434
|
+
let proposal = proposal_value(
|
|
6435
|
+
&state,
|
|
6436
|
+
artifact
|
|
6437
|
+
.get("proposal_id")
|
|
6438
|
+
.and_then(Value::as_str)
|
|
6439
|
+
.unwrap_or(""),
|
|
6440
|
+
)?;
|
|
5737
6441
|
if proposal.get("preview_id") != artifact.get("preview_id") {
|
|
5738
|
-
return Err(inspection_error(
|
|
6442
|
+
return Err(inspection_error(
|
|
6443
|
+
StatusCode::CONFLICT,
|
|
6444
|
+
"preview_invalidated",
|
|
6445
|
+
"preview has been replaced",
|
|
6446
|
+
));
|
|
5739
6447
|
}
|
|
5740
|
-
if proposal_readiness_value(&state, &tenant, &proposal, None)
|
|
5741
|
-
|
|
6448
|
+
if proposal_readiness_value(&state, &tenant, &proposal, None)?
|
|
6449
|
+
.get("stale")
|
|
6450
|
+
.and_then(Value::as_bool)
|
|
6451
|
+
== Some(true)
|
|
6452
|
+
{
|
|
6453
|
+
return Err(inspection_error(
|
|
6454
|
+
StatusCode::CONFLICT,
|
|
6455
|
+
"preview_invalidated",
|
|
6456
|
+
"the application authority changed after preview creation",
|
|
6457
|
+
));
|
|
5742
6458
|
}
|
|
5743
6459
|
redact_inspection_value(&mut artifact);
|
|
5744
|
-
if let Some(object) = artifact.as_object_mut() {
|
|
6460
|
+
if let Some(object) = artifact.as_object_mut() {
|
|
6461
|
+
object.remove("namespace");
|
|
6462
|
+
}
|
|
5745
6463
|
Ok(Json(artifact))
|
|
5746
6464
|
}
|
|
5747
6465
|
|
|
5748
6466
|
async fn inspect_proposal_preview_data(
|
|
5749
|
-
State(state): State<AppState>,
|
|
6467
|
+
State(state): State<AppState>,
|
|
6468
|
+
Extension(principal): Extension<Principal>,
|
|
6469
|
+
Path((id, collection)): Path<(String, String)>,
|
|
5750
6470
|
Query(input): Query<ProposalScopeRequest>,
|
|
5751
6471
|
) -> Result<Json<Value>, ApiError> {
|
|
5752
|
-
application_scope(
|
|
6472
|
+
application_scope(
|
|
6473
|
+
&state,
|
|
6474
|
+
&principal.key_id,
|
|
6475
|
+
&input.application_id,
|
|
6476
|
+
"application:read",
|
|
6477
|
+
)?;
|
|
5753
6478
|
let artifact = preview_artifact(&state, &id)?;
|
|
5754
6479
|
if !proposal_scope_matches(&artifact, &input.application_id, &input.environment) {
|
|
5755
|
-
return Err(inspection_error(
|
|
6480
|
+
return Err(inspection_error(
|
|
6481
|
+
StatusCode::NOT_FOUND,
|
|
6482
|
+
"preview_not_found",
|
|
6483
|
+
"preview not found",
|
|
6484
|
+
));
|
|
6485
|
+
}
|
|
6486
|
+
if !preview_available(&artifact) {
|
|
6487
|
+
return Err(inspection_error(
|
|
6488
|
+
StatusCode::GONE,
|
|
6489
|
+
"preview_expired",
|
|
6490
|
+
"preview artifact has expired",
|
|
6491
|
+
));
|
|
5756
6492
|
}
|
|
5757
|
-
if
|
|
5758
|
-
|
|
5759
|
-
|
|
6493
|
+
if collection.starts_with("_feltdb")
|
|
6494
|
+
|| !artifact
|
|
6495
|
+
.pointer("/proposed/schema")
|
|
6496
|
+
.and_then(Value::as_array)
|
|
6497
|
+
.is_some_and(|collections| {
|
|
6498
|
+
collections
|
|
6499
|
+
.iter()
|
|
6500
|
+
.any(|value| value.get("name").and_then(Value::as_str) == Some(&collection))
|
|
6501
|
+
})
|
|
6502
|
+
{
|
|
6503
|
+
return Err(inspection_error(
|
|
6504
|
+
StatusCode::NOT_FOUND,
|
|
6505
|
+
"collection_not_found",
|
|
6506
|
+
"collection is not part of the proposed contract",
|
|
6507
|
+
));
|
|
5760
6508
|
}
|
|
5761
6509
|
let limit = input.limit.unwrap_or(DEFAULT_INSPECTION_LIMIT);
|
|
5762
|
-
if limit == 0 || limit > MAX_INSPECTION_LIMIT {
|
|
6510
|
+
if limit == 0 || limit > MAX_INSPECTION_LIMIT {
|
|
6511
|
+
return Err(inspection_error(
|
|
6512
|
+
StatusCode::UNPROCESSABLE_ENTITY,
|
|
6513
|
+
"invalid_pagination",
|
|
6514
|
+
format!("limit must be between 1 and {MAX_INSPECTION_LIMIT}"),
|
|
6515
|
+
));
|
|
6516
|
+
}
|
|
5763
6517
|
let state_version = state.db.sequence()?;
|
|
5764
6518
|
let filter_hash = format!("{id}:{collection}");
|
|
5765
|
-
let after = input
|
|
5766
|
-
|
|
5767
|
-
|
|
6519
|
+
let after = input
|
|
6520
|
+
.cursor
|
|
6521
|
+
.as_deref()
|
|
6522
|
+
.map(|cursor| parse_inspection_cursor(cursor, &filter_hash, state_version, "preview-data"))
|
|
6523
|
+
.transpose()?;
|
|
6524
|
+
let capability = format!(
|
|
6525
|
+
"{}:{}",
|
|
6526
|
+
artifact
|
|
6527
|
+
.get("namespace")
|
|
6528
|
+
.and_then(Value::as_str)
|
|
6529
|
+
.unwrap_or(""),
|
|
6530
|
+
collection
|
|
6531
|
+
);
|
|
6532
|
+
let mut rows =
|
|
6533
|
+
state
|
|
6534
|
+
.db
|
|
6535
|
+
.list_collection_page(&capability, after.as_deref(), limit.saturating_add(1))?;
|
|
5768
6536
|
let has_more = rows.len() > limit;
|
|
5769
6537
|
rows.truncate(limit);
|
|
5770
6538
|
let last_key = rows.last().map(|row| row.key.clone());
|
|
5771
6539
|
let mut records = rows.into_iter().map(|row| row.value).collect::<Vec<_>>();
|
|
5772
|
-
for record in &mut records {
|
|
5773
|
-
|
|
5774
|
-
|
|
6540
|
+
for record in &mut records {
|
|
6541
|
+
redact_inspection_value(record);
|
|
6542
|
+
}
|
|
6543
|
+
let next_cursor = if has_more {
|
|
6544
|
+
last_key.map(|key| inspection_cursor(&filter_hash, state_version, &key, "preview-data"))
|
|
6545
|
+
} else {
|
|
6546
|
+
None
|
|
6547
|
+
};
|
|
6548
|
+
Ok(Json(
|
|
6549
|
+
json!({"preview_id":id,"collection":collection,"records":records,"data_classification":"synthetic_preview","pagination":{"limit":limit,"nextCursor":next_cursor}}),
|
|
6550
|
+
))
|
|
5775
6551
|
}
|
|
5776
6552
|
|
|
5777
6553
|
async fn simulate_proposal_preview(
|
|
5778
|
-
State(state): State<AppState>,
|
|
5779
|
-
|
|
6554
|
+
State(state): State<AppState>,
|
|
6555
|
+
Extension(principal): Extension<Principal>,
|
|
6556
|
+
Path(id): Path<String>,
|
|
6557
|
+
Query(input): Query<ProposalScopeRequest>,
|
|
6558
|
+
Json(simulation): Json<PreviewSimulationRequest>,
|
|
5780
6559
|
) -> Result<Json<Value>, ApiError> {
|
|
5781
|
-
application_scope(
|
|
6560
|
+
application_scope(
|
|
6561
|
+
&state,
|
|
6562
|
+
&principal.key_id,
|
|
6563
|
+
&input.application_id,
|
|
6564
|
+
"application:write",
|
|
6565
|
+
)?;
|
|
5782
6566
|
let artifact = preview_artifact(&state, &id)?;
|
|
5783
|
-
if !proposal_scope_matches(&artifact, &input.application_id, &input.environment) {
|
|
5784
|
-
|
|
5785
|
-
|
|
5786
|
-
|
|
5787
|
-
|
|
5788
|
-
|
|
6567
|
+
if !proposal_scope_matches(&artifact, &input.application_id, &input.environment) {
|
|
6568
|
+
return Err(inspection_error(
|
|
6569
|
+
StatusCode::NOT_FOUND,
|
|
6570
|
+
"preview_not_found",
|
|
6571
|
+
"preview not found",
|
|
6572
|
+
));
|
|
6573
|
+
}
|
|
6574
|
+
if !preview_available(&artifact) {
|
|
6575
|
+
return Err(inspection_error(
|
|
6576
|
+
StatusCode::GONE,
|
|
6577
|
+
"preview_expired",
|
|
6578
|
+
"preview artifact has expired",
|
|
6579
|
+
));
|
|
6580
|
+
}
|
|
6581
|
+
let known = artifact
|
|
6582
|
+
.get("events")
|
|
6583
|
+
.and_then(Value::as_array)
|
|
6584
|
+
.is_some_and(|events| {
|
|
6585
|
+
events
|
|
6586
|
+
.iter()
|
|
6587
|
+
.any(|event| event.as_str() == Some(&simulation.event))
|
|
6588
|
+
});
|
|
6589
|
+
if !known {
|
|
6590
|
+
return Err(inspection_error(
|
|
6591
|
+
StatusCode::UNPROCESSABLE_ENTITY,
|
|
6592
|
+
"unsupported_preview_event",
|
|
6593
|
+
"event is not part of the proposal",
|
|
6594
|
+
));
|
|
6595
|
+
}
|
|
6596
|
+
let state_flow = artifact
|
|
6597
|
+
.get("state_flow")
|
|
6598
|
+
.cloned()
|
|
6599
|
+
.unwrap_or_else(|| json!([]));
|
|
6600
|
+
Ok(Json(
|
|
6601
|
+
json!({"preview_id":id,"event":simulation.event,"status":"simulated","external_side_effects":"none","trace":state_flow,"writes":"isolated_preview_namespace_only"}),
|
|
6602
|
+
))
|
|
5789
6603
|
}
|
|
5790
6604
|
async fn transition_proposal(
|
|
5791
6605
|
State(state): State<AppState>,
|
|
@@ -5814,29 +6628,70 @@ async fn transition_proposal(
|
|
|
5814
6628
|
));
|
|
5815
6629
|
}
|
|
5816
6630
|
if proposal_is_expired(&value) && input.status != "expired" {
|
|
5817
|
-
return Err(inspection_error(
|
|
6631
|
+
return Err(inspection_error(
|
|
6632
|
+
StatusCode::CONFLICT,
|
|
6633
|
+
"proposal_expired",
|
|
6634
|
+
"proposal has expired",
|
|
6635
|
+
));
|
|
5818
6636
|
}
|
|
5819
6637
|
if input.status == "approved" {
|
|
5820
6638
|
let readiness = proposal_readiness_value(&state, &tenant, &value, None)?;
|
|
5821
6639
|
if readiness.get("readiness").and_then(Value::as_str) != Some("ready") {
|
|
5822
|
-
return Err(ApiError::structured(
|
|
6640
|
+
return Err(ApiError::structured(
|
|
6641
|
+
StatusCode::CONFLICT,
|
|
6642
|
+
json!({"code":"stale_proposal","message":"proposal is no longer applicable","readiness":readiness}),
|
|
6643
|
+
));
|
|
5823
6644
|
}
|
|
5824
|
-
let preview_id = value
|
|
6645
|
+
let preview_id = value
|
|
6646
|
+
.get("preview_id")
|
|
6647
|
+
.and_then(Value::as_str)
|
|
6648
|
+
.ok_or_else(|| {
|
|
6649
|
+
inspection_error(
|
|
6650
|
+
StatusCode::CONFLICT,
|
|
6651
|
+
"preview_required",
|
|
6652
|
+
"approval requires a ready preview artifact",
|
|
6653
|
+
)
|
|
6654
|
+
})?;
|
|
5825
6655
|
let artifact = preview_artifact(&state, preview_id)?;
|
|
5826
6656
|
let bound = preview_available(&artifact)
|
|
5827
6657
|
&& artifact.get("proposal_id").and_then(Value::as_str) == Some(id.as_str())
|
|
5828
6658
|
&& artifact.get("proposal_contract_hash") == value.get("base_contract_hash")
|
|
5829
6659
|
&& artifact.get("proposal_flow_hash") == value.get("base_flow_hash")
|
|
5830
6660
|
&& artifact.get("module_versions") == value.get("module_versions");
|
|
5831
|
-
if !bound {
|
|
6661
|
+
if !bound {
|
|
6662
|
+
return Err(inspection_error(
|
|
6663
|
+
StatusCode::CONFLICT,
|
|
6664
|
+
"preview_invalid",
|
|
6665
|
+
"approval requires a current preview bound to this proposal",
|
|
6666
|
+
));
|
|
6667
|
+
}
|
|
5832
6668
|
}
|
|
5833
6669
|
if input.status == "applied" {
|
|
5834
6670
|
let configured = std::env::var("FELTDB_PROPOSAL_AUTHORITY_TOKEN").ok();
|
|
5835
|
-
let supplied = headers
|
|
5836
|
-
|
|
5837
|
-
|
|
6671
|
+
let supplied = headers
|
|
6672
|
+
.get("FeltDB-Proposal-Authority")
|
|
6673
|
+
.and_then(|value| value.to_str().ok());
|
|
6674
|
+
let repository_apply = headers
|
|
6675
|
+
.get("FeltDB-Repository-Apply")
|
|
6676
|
+
.and_then(|value| value.to_str().ok())
|
|
6677
|
+
== Some("1");
|
|
6678
|
+
let authorized = configured
|
|
6679
|
+
.as_deref()
|
|
6680
|
+
.zip(supplied)
|
|
6681
|
+
.is_some_and(|(expected, actual)| {
|
|
6682
|
+
expected.len() == actual.len()
|
|
6683
|
+
&& ring::constant_time::verify_slices_are_equal(
|
|
6684
|
+
expected.as_bytes(),
|
|
6685
|
+
actual.as_bytes(),
|
|
6686
|
+
)
|
|
6687
|
+
.is_ok()
|
|
6688
|
+
});
|
|
5838
6689
|
if !repository_apply || !authorized {
|
|
5839
|
-
return Err(inspection_error(
|
|
6690
|
+
return Err(inspection_error(
|
|
6691
|
+
StatusCode::FORBIDDEN,
|
|
6692
|
+
"repository_authority_required",
|
|
6693
|
+
"valid repository application authority is required",
|
|
6694
|
+
));
|
|
5840
6695
|
}
|
|
5841
6696
|
}
|
|
5842
6697
|
if input.status == "approved"
|
|
@@ -5857,37 +6712,59 @@ async fn transition_proposal(
|
|
|
5857
6712
|
));
|
|
5858
6713
|
}
|
|
5859
6714
|
if input.status == "rejected" {
|
|
5860
|
-
if let Some(reason) = input.reason {
|
|
6715
|
+
if let Some(reason) = input.reason {
|
|
6716
|
+
value["rejection_reason"] = json!(reason);
|
|
6717
|
+
}
|
|
5861
6718
|
}
|
|
5862
|
-
let mut moved = move_proposal(
|
|
5863
|
-
&state,
|
|
5864
|
-
&id,
|
|
5865
|
-
value,
|
|
5866
|
-
&input.status,
|
|
5867
|
-
&principal.key_id,
|
|
5868
|
-
)?;
|
|
6719
|
+
let mut moved = move_proposal(&state, &id, value, &input.status, &principal.key_id)?;
|
|
5869
6720
|
redact_inspection_value(&mut moved);
|
|
5870
6721
|
Ok(Json(moved))
|
|
5871
6722
|
}
|
|
5872
6723
|
|
|
5873
6724
|
async fn approve_proposal(
|
|
5874
|
-
state: State<AppState>,
|
|
5875
|
-
|
|
6725
|
+
state: State<AppState>,
|
|
6726
|
+
principal: Extension<Principal>,
|
|
6727
|
+
headers: HeaderMap,
|
|
6728
|
+
path: Path<String>,
|
|
6729
|
+
Json(input): Json<ProposalLifecycleRequest>,
|
|
5876
6730
|
) -> Result<Json<Value>, ApiError> {
|
|
5877
|
-
transition_proposal(
|
|
5878
|
-
|
|
5879
|
-
|
|
5880
|
-
|
|
6731
|
+
transition_proposal(
|
|
6732
|
+
state,
|
|
6733
|
+
principal,
|
|
6734
|
+
headers,
|
|
6735
|
+
path,
|
|
6736
|
+
Json(ProposalStatusRequest {
|
|
6737
|
+
application_id: input.application_id,
|
|
6738
|
+
environment: input.environment,
|
|
6739
|
+
status: "approved".into(),
|
|
6740
|
+
approve_authorization: input.approve_authorization,
|
|
6741
|
+
reason: input.reason,
|
|
6742
|
+
}),
|
|
6743
|
+
)
|
|
6744
|
+
.await
|
|
5881
6745
|
}
|
|
5882
6746
|
|
|
5883
6747
|
async fn reject_proposal(
|
|
5884
|
-
state: State<AppState>,
|
|
5885
|
-
|
|
6748
|
+
state: State<AppState>,
|
|
6749
|
+
principal: Extension<Principal>,
|
|
6750
|
+
headers: HeaderMap,
|
|
6751
|
+
path: Path<String>,
|
|
6752
|
+
Json(input): Json<ProposalLifecycleRequest>,
|
|
5886
6753
|
) -> Result<Json<Value>, ApiError> {
|
|
5887
|
-
transition_proposal(
|
|
5888
|
-
|
|
5889
|
-
|
|
5890
|
-
|
|
6754
|
+
transition_proposal(
|
|
6755
|
+
state,
|
|
6756
|
+
principal,
|
|
6757
|
+
headers,
|
|
6758
|
+
path,
|
|
6759
|
+
Json(ProposalStatusRequest {
|
|
6760
|
+
application_id: input.application_id,
|
|
6761
|
+
environment: input.environment,
|
|
6762
|
+
status: "rejected".into(),
|
|
6763
|
+
approve_authorization: false,
|
|
6764
|
+
reason: input.reason,
|
|
6765
|
+
}),
|
|
6766
|
+
)
|
|
6767
|
+
.await
|
|
5891
6768
|
}
|
|
5892
6769
|
fn auth_response(
|
|
5893
6770
|
identity: feltdb_server::identity::Identity,
|
|
@@ -5916,17 +6793,18 @@ async fn auth_sign_up(
|
|
|
5916
6793
|
"application not found".into(),
|
|
5917
6794
|
));
|
|
5918
6795
|
}
|
|
5919
|
-
let (identity, session, token) =
|
|
5920
|
-
|
|
5921
|
-
.sign_up(&input.email, &input.password, input.display_name)
|
|
5922
|
-
{
|
|
5923
|
-
Ok(created) => created,
|
|
5924
|
-
Err(error) if error == "identity already exists" => state
|
|
6796
|
+
let (identity, session, token) =
|
|
6797
|
+
match state
|
|
5925
6798
|
.identities
|
|
5926
|
-
.
|
|
5927
|
-
|
|
5928
|
-
|
|
5929
|
-
|
|
6799
|
+
.sign_up(&input.email, &input.password, input.display_name)
|
|
6800
|
+
{
|
|
6801
|
+
Ok(created) => created,
|
|
6802
|
+
Err(error) if error == "identity already exists" => state
|
|
6803
|
+
.identities
|
|
6804
|
+
.sign_in(&input.email, &input.password)
|
|
6805
|
+
.map_err(|_| ApiError(StatusCode::UNAUTHORIZED, "invalid credentials".into()))?,
|
|
6806
|
+
Err(error) => return Err(ApiError(StatusCode::BAD_REQUEST, error)),
|
|
6807
|
+
};
|
|
5930
6808
|
state
|
|
5931
6809
|
.tenancy
|
|
5932
6810
|
.register_platform_user(
|
|
@@ -5950,7 +6828,10 @@ async fn auth_sign_in(
|
|
|
5950
6828
|
.map_err(|_| ApiError(StatusCode::UNAUTHORIZED, "invalid credentials".into()))?;
|
|
5951
6829
|
if let Some(application_id) = input.application_id {
|
|
5952
6830
|
if state.tenancy.application_tenant(&application_id).is_none() {
|
|
5953
|
-
return Err(ApiError(
|
|
6831
|
+
return Err(ApiError(
|
|
6832
|
+
StatusCode::NOT_FOUND,
|
|
6833
|
+
"application not found".into(),
|
|
6834
|
+
));
|
|
5954
6835
|
}
|
|
5955
6836
|
state
|
|
5956
6837
|
.tenancy
|
|
@@ -7341,7 +8222,9 @@ fn validate_production_configuration(config: &Config) -> Result<(), Box<dyn std:
|
|
|
7341
8222
|
Ok(())
|
|
7342
8223
|
}
|
|
7343
8224
|
|
|
7344
|
-
|
|
8225
|
+
// Durable stores currently perform bounded synchronous filesystem operations.
|
|
8226
|
+
// Keep independent health and API requests schedulable on single-vCPU hosts.
|
|
8227
|
+
#[tokio::main(flavor = "multi_thread", worker_threads = 4)]
|
|
7345
8228
|
async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
|
7346
8229
|
tracing_subscriber::fmt()
|
|
7347
8230
|
.with_env_filter(tracing_subscriber::EnvFilter::from_default_env())
|
|
@@ -7536,6 +8419,10 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
|
|
7536
8419
|
.patch(update_application_control)
|
|
7537
8420
|
.delete(delete_application_control),
|
|
7538
8421
|
)
|
|
8422
|
+
.route(
|
|
8423
|
+
"/api/applications/{application_id}/qualification/destroy",
|
|
8424
|
+
axum::routing::post(delete_qualification_application_control),
|
|
8425
|
+
)
|
|
7539
8426
|
.route(
|
|
7540
8427
|
"/api/applications/{application_id}/members",
|
|
7541
8428
|
get(list_application_members),
|
|
@@ -7715,10 +8602,19 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
|
|
7715
8602
|
"/v1/proposals/{id}/status",
|
|
7716
8603
|
get(proposal_status).patch(transition_proposal),
|
|
7717
8604
|
)
|
|
7718
|
-
.route(
|
|
8605
|
+
.route(
|
|
8606
|
+
"/v1/proposals/{id}/convergence",
|
|
8607
|
+
get(proposal_convergence).post(record_proposal_convergence),
|
|
8608
|
+
)
|
|
7719
8609
|
.route("/v1/previews/{id}", get(get_proposal_preview))
|
|
7720
|
-
.route(
|
|
7721
|
-
|
|
8610
|
+
.route(
|
|
8611
|
+
"/v1/previews/{id}/data/{collection}",
|
|
8612
|
+
get(inspect_proposal_preview_data),
|
|
8613
|
+
)
|
|
8614
|
+
.route(
|
|
8615
|
+
"/v1/previews/{id}/simulate",
|
|
8616
|
+
axum::routing::post(simulate_proposal_preview),
|
|
8617
|
+
)
|
|
7722
8618
|
.route("/v1/auth/session", get(auth_session))
|
|
7723
8619
|
.route("/v1/auth/signout", axum::routing::post(auth_sign_out))
|
|
7724
8620
|
.route("/v1/events", get(events))
|
|
@@ -8103,9 +8999,15 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
|
|
8103
8999
|
.route("/v1/auth/signup", axum::routing::post(auth_sign_up))
|
|
8104
9000
|
.route("/v1/auth/signin", axum::routing::post(auth_sign_in))
|
|
8105
9001
|
.route("/internal/intents/by-hash", get(internal_find_intent))
|
|
8106
|
-
.route(
|
|
9002
|
+
.route(
|
|
9003
|
+
"/internal/intents",
|
|
9004
|
+
axum::routing::post(internal_create_intent),
|
|
9005
|
+
)
|
|
8107
9006
|
.route("/internal/intents/{id}", put(internal_replace_intent))
|
|
8108
|
-
.route(
|
|
9007
|
+
.route(
|
|
9008
|
+
"/internal/intent-events",
|
|
9009
|
+
axum::routing::post(internal_create_intent_event),
|
|
9010
|
+
)
|
|
8109
9011
|
.route("/ready", get(readiness))
|
|
8110
9012
|
.route("/runtime", get(runtime))
|
|
8111
9013
|
.route("/metrics", get(network_metrics))
|
|
@@ -8186,7 +9088,14 @@ async fn authenticate(
|
|
|
8186
9088
|
.and_then(|header| header.to_str().ok())
|
|
8187
9089
|
.and_then(|header| header.strip_prefix("Bearer "));
|
|
8188
9090
|
let machine = token.and_then(|token| state.keys.authenticate(token, &state.namespace));
|
|
8189
|
-
|
|
9091
|
+
// A valid service key is already authoritative. Do not feed that
|
|
9092
|
+
// high-entropy bearer token through the Argon2-backed human-session
|
|
9093
|
+
// verifier as well; doing so blocks the request worker needlessly.
|
|
9094
|
+
let actor = if machine.is_none() {
|
|
9095
|
+
token.and_then(|token| state.identities.authenticate_session(token))
|
|
9096
|
+
} else {
|
|
9097
|
+
None
|
|
9098
|
+
};
|
|
8190
9099
|
let human = request
|
|
8191
9100
|
.headers()
|
|
8192
9101
|
.get(COOKIE)
|
|
@@ -10787,10 +11696,9 @@ fn put_canonical(
|
|
|
10787
11696
|
|
|
10788
11697
|
/// The authority's current committed-state revision.
|
|
10789
11698
|
///
|
|
10790
|
-
/// This is the value `apply_atomic_transaction` advances
|
|
10791
|
-
///
|
|
10792
|
-
///
|
|
10793
|
-
/// it without performing a write, which is what a cache needs.
|
|
11699
|
+
/// This is the commit-level value `apply_atomic_transaction` advances exactly
|
|
11700
|
+
/// once per successful transaction. It survives restart and never moves
|
|
11701
|
+
/// backwards.
|
|
10794
11702
|
///
|
|
10795
11703
|
/// `scope` is the store's instance id. A revision is comparable only against
|
|
10796
11704
|
/// another revision carrying the same scope, so a client that fails over to a
|
|
@@ -10804,7 +11712,7 @@ struct RevisionResponse {
|
|
|
10804
11712
|
|
|
10805
11713
|
async fn get_revision(State(state): State<AppState>) -> Result<Json<RevisionResponse>, ApiError> {
|
|
10806
11714
|
Ok(Json(RevisionResponse {
|
|
10807
|
-
revision: state.db.
|
|
11715
|
+
revision: state.db.current_revision()?,
|
|
10808
11716
|
scope: state.db.instance_id()?,
|
|
10809
11717
|
}))
|
|
10810
11718
|
}
|
|
@@ -11087,7 +11995,12 @@ async fn list_records(
|
|
|
11087
11995
|
State(state): State<AppState>,
|
|
11088
11996
|
Path(collection): Path<String>,
|
|
11089
11997
|
) -> Result<Json<Vec<RecordResponse>>, ApiError> {
|
|
11090
|
-
if collection == PROPOSAL_COLLECTION
|
|
11998
|
+
if collection == PROPOSAL_COLLECTION
|
|
11999
|
+
|| collection == PROPOSAL_EVENT_COLLECTION
|
|
12000
|
+
|| collection == CONVERGENCE_COLLECTION
|
|
12001
|
+
|| collection == INTENT_COLLECTION
|
|
12002
|
+
|| collection == INTENT_EVENT_COLLECTION
|
|
12003
|
+
{
|
|
11091
12004
|
return Err(ApiError(
|
|
11092
12005
|
StatusCode::FORBIDDEN,
|
|
11093
12006
|
"system collection requires proposal service".into(),
|
|
@@ -11102,7 +12015,12 @@ async fn get_record(
|
|
|
11102
12015
|
State(state): State<AppState>,
|
|
11103
12016
|
Path((collection, id)): Path<(String, String)>,
|
|
11104
12017
|
) -> Result<Json<RecordResponse>, ApiError> {
|
|
11105
|
-
if collection == PROPOSAL_COLLECTION
|
|
12018
|
+
if collection == PROPOSAL_COLLECTION
|
|
12019
|
+
|| collection == PROPOSAL_EVENT_COLLECTION
|
|
12020
|
+
|| collection == CONVERGENCE_COLLECTION
|
|
12021
|
+
|| collection == INTENT_COLLECTION
|
|
12022
|
+
|| collection == INTENT_EVENT_COLLECTION
|
|
12023
|
+
{
|
|
11106
12024
|
return Err(ApiError(
|
|
11107
12025
|
StatusCode::FORBIDDEN,
|
|
11108
12026
|
"system collection requires proposal service".into(),
|
|
@@ -11184,6 +12102,9 @@ struct AtomicTxRequest {
|
|
|
11184
12102
|
#[serde(rename_all = "camelCase")]
|
|
11185
12103
|
struct AtomicTxResponse {
|
|
11186
12104
|
transaction_id: String,
|
|
12105
|
+
base_revision: u64,
|
|
12106
|
+
commit_revision: u64,
|
|
12107
|
+
status: feltdb::TransactionStatus,
|
|
11187
12108
|
/// True when this id had already been committed; nothing was reapplied.
|
|
11188
12109
|
duplicate: bool,
|
|
11189
12110
|
operations: usize,
|
|
@@ -11335,6 +12256,9 @@ async fn commit_transaction(
|
|
|
11335
12256
|
},
|
|
11336
12257
|
Json(AtomicTxResponse {
|
|
11337
12258
|
transaction_id: commit.transaction_id,
|
|
12259
|
+
base_revision: commit.base_revision,
|
|
12260
|
+
commit_revision: commit.commit_revision,
|
|
12261
|
+
status: commit.status,
|
|
11338
12262
|
duplicate: commit.duplicate,
|
|
11339
12263
|
operations,
|
|
11340
12264
|
state_before: commit.state_before,
|
|
@@ -11348,7 +12272,12 @@ async fn create_record(
|
|
|
11348
12272
|
Path(collection): Path<String>,
|
|
11349
12273
|
Json(mut record): Json<CreateRecord>,
|
|
11350
12274
|
) -> Result<(StatusCode, Json<RecordResponse>), ApiError> {
|
|
11351
|
-
if collection == PROPOSAL_COLLECTION
|
|
12275
|
+
if collection == PROPOSAL_COLLECTION
|
|
12276
|
+
|| collection == PROPOSAL_EVENT_COLLECTION
|
|
12277
|
+
|| collection == CONVERGENCE_COLLECTION
|
|
12278
|
+
|| collection == INTENT_COLLECTION
|
|
12279
|
+
|| collection == INTENT_EVENT_COLLECTION
|
|
12280
|
+
{
|
|
11352
12281
|
return Err(ApiError(
|
|
11353
12282
|
StatusCode::FORBIDDEN,
|
|
11354
12283
|
"system collection requires proposal service".into(),
|
|
@@ -11387,7 +12316,12 @@ async fn update_record(
|
|
|
11387
12316
|
Path((collection, id)): Path<(String, String)>,
|
|
11388
12317
|
Json(changes): Json<Map<String, Value>>,
|
|
11389
12318
|
) -> Result<Json<RecordResponse>, ApiError> {
|
|
11390
|
-
if collection == PROPOSAL_COLLECTION
|
|
12319
|
+
if collection == PROPOSAL_COLLECTION
|
|
12320
|
+
|| collection == PROPOSAL_EVENT_COLLECTION
|
|
12321
|
+
|| collection == CONVERGENCE_COLLECTION
|
|
12322
|
+
|| collection == INTENT_COLLECTION
|
|
12323
|
+
|| collection == INTENT_EVENT_COLLECTION
|
|
12324
|
+
{
|
|
11391
12325
|
return Err(ApiError(
|
|
11392
12326
|
StatusCode::FORBIDDEN,
|
|
11393
12327
|
"system collection requires proposal service".into(),
|
|
@@ -11508,7 +12442,12 @@ async fn delete_record(
|
|
|
11508
12442
|
State(state): State<AppState>,
|
|
11509
12443
|
Path((collection, id)): Path<(String, String)>,
|
|
11510
12444
|
) -> Result<StatusCode, ApiError> {
|
|
11511
|
-
if collection == PROPOSAL_COLLECTION
|
|
12445
|
+
if collection == PROPOSAL_COLLECTION
|
|
12446
|
+
|| collection == PROPOSAL_EVENT_COLLECTION
|
|
12447
|
+
|| collection == CONVERGENCE_COLLECTION
|
|
12448
|
+
|| collection == INTENT_COLLECTION
|
|
12449
|
+
|| collection == INTENT_EVENT_COLLECTION
|
|
12450
|
+
{
|
|
11512
12451
|
return Err(ApiError(
|
|
11513
12452
|
StatusCode::FORBIDDEN,
|
|
11514
12453
|
"system collection requires proposal service".into(),
|