@feltdb/core 0.8.0 → 0.8.2
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/application-development.d.ts +7 -2
- package/dist/application-development.d.ts.map +1 -1
- package/dist/application-development.js +7 -13
- package/dist/application-manifest.d.ts +1 -0
- package/dist/application-manifest.d.ts.map +1 -1
- package/dist/application-manifest.js +1 -1
- package/dist/application-understanding.d.ts +76 -0
- package/dist/application-understanding.d.ts.map +1 -0
- package/dist/application-understanding.js +282 -0
- package/dist/cli/ai-model-config.js +3 -3
- package/dist/cli/commands.js +131 -38
- package/dist/cli/proposal-client.js +5 -1
- package/dist/cli/publish-engine.js +1 -1
- package/dist/cli/repository-evidence.js +55 -0
- package/dist/cli/source-sync.js +190 -75
- package/dist/convergence.d.ts +55 -0
- package/dist/convergence.d.ts.map +1 -0
- package/dist/convergence.js +63 -0
- package/dist/create/managed-account.js +1 -0
- package/dist/create/package-versions.js +1 -1
- package/dist/create/server-source/crates/feltdb/src/application.rs +23 -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/connections.rs +14 -0
- 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 +1262 -173
- package/dist/create/server-source/crates/feltdb-server/src/tenancy.rs +142 -1
- package/dist/create/template/default-project/src/context/AuthContext.tsx +9 -1
- package/dist/create/template/default-project/src/pages/SignUp.tsx +1 -1
- package/dist/flowspec.d.ts.map +1 -1
- package/dist/flowspec.js +6 -1
- package/dist/http-client.d.ts +32 -1
- package/dist/http-client.d.ts.map +1 -1
- package/dist/http-client.js +14 -2
- package/dist/http-db.d.ts +6 -0
- package/dist/http-db.d.ts.map +1 -1
- package/dist/http-db.js +30 -4
- package/dist/index-core.d.ts +3 -0
- package/dist/index-core.d.ts.map +1 -1
- package/dist/index-core.js +3 -0
- package/dist/intent.d.ts +98 -0
- package/dist/intent.d.ts.map +1 -0
- package/dist/intent.js +343 -0
- package/dist/module-intent.d.ts +19 -5
- package/dist/module-intent.d.ts.map +1 -1
- package/dist/module-intent.js +211 -62
- package/dist/module.d.ts +41 -12
- package/dist/module.d.ts.map +1 -1
- package/dist/module.js +543 -38
- package/dist/proposal.d.ts +13 -1
- package/dist/proposal.d.ts.map +1 -1
- package/dist/proposal.js +21 -4
- 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/components/AskFeltDB.d.ts.map +1 -1
- package/dist/studio/components/ProposalBrowser.d.ts +1 -1
- package/dist/studio/components/ProposalBrowser.d.ts.map +1 -1
- package/dist/studio/components/index.js +1 -1
- package/dist/studio/{components-CNSNrmA9.js → components-Duq2xQWt.js} +251 -193
- package/dist/studio/index.js +1 -1
- package/dist/studio/studio.css +1 -1
- package/dist/studio/utils/proposal-api.d.ts +4 -1
- package/dist/studio/utils/proposal-api.d.ts.map +1 -1
- package/dist/studio/utils/proposal-api.js +3 -0
- package/dist/studio-app/assets/{feltdb_wasm-CSB6KVgw.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-B0k4UAlI.js +29 -0
- package/dist/studio-app/assets/{index-yazaWMUN.css → index-Cd_NPw14.css} +1 -1
- package/dist/studio-app/index.html +2 -2
- package/dist/wasm/feltdb_wasm_bg.wasm +0 -0
- package/dist/workspace/local-development-authority.d.ts +2 -0
- package/dist/workspace/local-development-authority.d.ts.map +1 -1
- package/dist/workspace/local-development-authority.js +65 -2
- package/package.json +1 -1
- package/dist/studio-app/assets/dist-CCOk39Uc.js +0 -1
- package/dist/studio-app/assets/feltdb_wasm_bg-Dd1fnO9U.wasm +0 -0
- package/dist/studio-app/assets/index-Bncji3aN.js +0 -43
- package/dist/studio-app/assets/lib-B2_7fcn7.js +0 -69
- package/dist/studio-app/assets/worker-CARZ5g7K.js +0 -69
|
@@ -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>,
|
|
239
|
+
}
|
|
240
|
+
#[derive(Deserialize)]
|
|
241
|
+
struct QualificationDestroyRequest {
|
|
242
|
+
proof_run_id: String,
|
|
233
243
|
}
|
|
234
244
|
#[derive(Deserialize)]
|
|
245
|
+
#[serde(deny_unknown_fields)]
|
|
235
246
|
struct UpdateApplicationRequest {
|
|
236
247
|
name: String,
|
|
237
248
|
}
|
|
@@ -448,6 +459,8 @@ struct ProposalScopeRequest {
|
|
|
448
459
|
limit: Option<usize>,
|
|
449
460
|
#[serde(default)]
|
|
450
461
|
cursor: Option<String>,
|
|
462
|
+
#[serde(default)]
|
|
463
|
+
current_evidence_hash: Option<String>,
|
|
451
464
|
}
|
|
452
465
|
#[derive(Deserialize)]
|
|
453
466
|
struct CreateProposalRequest {
|
|
@@ -472,6 +485,26 @@ struct CreateProposalRequest {
|
|
|
472
485
|
expires_at: Option<String>,
|
|
473
486
|
#[serde(default)]
|
|
474
487
|
status: Option<String>,
|
|
488
|
+
#[serde(default)]
|
|
489
|
+
understanding: Option<Value>,
|
|
490
|
+
#[serde(default)]
|
|
491
|
+
evidence_hash: Option<String>,
|
|
492
|
+
#[serde(default)]
|
|
493
|
+
understanding_hash: Option<String>,
|
|
494
|
+
}
|
|
495
|
+
#[derive(Deserialize, Serialize)]
|
|
496
|
+
struct ExecuteIntentRequest {
|
|
497
|
+
application_id: String,
|
|
498
|
+
#[serde(default = "production_environment")]
|
|
499
|
+
environment: String,
|
|
500
|
+
#[serde(alias = "raw_request")]
|
|
501
|
+
request: String,
|
|
502
|
+
}
|
|
503
|
+
#[derive(Deserialize)]
|
|
504
|
+
struct IntentHashRequest {
|
|
505
|
+
application_id: String,
|
|
506
|
+
environment: String,
|
|
507
|
+
intent_hash: String,
|
|
475
508
|
}
|
|
476
509
|
#[derive(Deserialize)]
|
|
477
510
|
struct ProposalActorRequest {
|
|
@@ -518,6 +551,7 @@ struct SignUpRequest {
|
|
|
518
551
|
}
|
|
519
552
|
#[derive(Deserialize)]
|
|
520
553
|
struct SignInRequest {
|
|
554
|
+
application_id: Option<String>,
|
|
521
555
|
email: String,
|
|
522
556
|
password: String,
|
|
523
557
|
}
|
|
@@ -2219,6 +2253,7 @@ async fn platform_overview(
|
|
|
2219
2253
|
let (users, tenants, applications, tenant_memberships, application_memberships) =
|
|
2220
2254
|
state.tenancy.platform_inventory().map_err(control_error)?;
|
|
2221
2255
|
let identities = state.identities.list().map_err(control_error)?;
|
|
2256
|
+
let runtime_metrics = state.metrics.snapshot();
|
|
2222
2257
|
Ok(Json(json!({
|
|
2223
2258
|
"owner": principal.identity_id,
|
|
2224
2259
|
"users": users,
|
|
@@ -2227,6 +2262,11 @@ async fn platform_overview(
|
|
|
2227
2262
|
"tenant_memberships": tenant_memberships,
|
|
2228
2263
|
"application_memberships": application_memberships,
|
|
2229
2264
|
"identities": identities,
|
|
2265
|
+
"adoption": {
|
|
2266
|
+
"measured_at": unix_seconds_i64().max(0) as u64,
|
|
2267
|
+
"runtime_process_started_seconds_ago": state.started_at.elapsed().as_secs(),
|
|
2268
|
+
"runtime": runtime_metrics,
|
|
2269
|
+
},
|
|
2230
2270
|
})))
|
|
2231
2271
|
}
|
|
2232
2272
|
async fn platform_update_user(
|
|
@@ -2319,7 +2359,12 @@ async fn create_application_control(
|
|
|
2319
2359
|
) -> Result<(StatusCode, Json<Value>), ApiError> {
|
|
2320
2360
|
let app = state
|
|
2321
2361
|
.tenancy
|
|
2322
|
-
.
|
|
2362
|
+
.create_application_with_qualification(
|
|
2363
|
+
&principal.key_id,
|
|
2364
|
+
&tenant_id,
|
|
2365
|
+
&input.name,
|
|
2366
|
+
input.qualification,
|
|
2367
|
+
)
|
|
2323
2368
|
.map_err(control_error)?;
|
|
2324
2369
|
Ok((StatusCode::CREATED, Json(json!(app))))
|
|
2325
2370
|
}
|
|
@@ -2349,6 +2394,23 @@ async fn delete_application_control(
|
|
|
2349
2394
|
Ok(StatusCode::NO_CONTENT)
|
|
2350
2395
|
}
|
|
2351
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
|
+
|
|
2352
2414
|
async fn delete_certification_fixture(
|
|
2353
2415
|
State(state): State<AppState>,
|
|
2354
2416
|
Path(application_id): Path<String>,
|
|
@@ -3793,6 +3855,9 @@ fn application_scope(
|
|
|
3793
3855
|
StatusCode::NOT_FOUND,
|
|
3794
3856
|
"application not found".into(),
|
|
3795
3857
|
))?;
|
|
3858
|
+
if std::env::var("FELTDB_INTENT_AUTHORITY_KEY_ID").as_deref() == Ok(actor) {
|
|
3859
|
+
return Ok(tenant_id);
|
|
3860
|
+
}
|
|
3796
3861
|
if !state
|
|
3797
3862
|
.tenancy
|
|
3798
3863
|
.authorize_application(actor, &tenant_id, application_id, capability)
|
|
@@ -4449,13 +4514,13 @@ async fn import_application_draft(
|
|
|
4449
4514
|
|
|
4450
4515
|
fn runtime_inventory(
|
|
4451
4516
|
state: &AppState,
|
|
4452
|
-
|
|
4517
|
+
_actor: &str,
|
|
4453
4518
|
application_id: &str,
|
|
4454
4519
|
manifest: &ApplicationManifest,
|
|
4455
4520
|
) -> Result<RuntimeInventory, ApiError> {
|
|
4456
4521
|
let connections = state
|
|
4457
4522
|
.connections
|
|
4458
|
-
.
|
|
4523
|
+
.application_connections(application_id)
|
|
4459
4524
|
.map_err(control_error)?;
|
|
4460
4525
|
let connection_ids = connections
|
|
4461
4526
|
.into_iter()
|
|
@@ -5046,14 +5111,297 @@ async fn inspect_record(
|
|
|
5046
5111
|
const PROPOSAL_COLLECTION: &str = "_feltdb_proposals";
|
|
5047
5112
|
const PROPOSAL_EVENT_COLLECTION: &str = "_feltdb_proposal_events";
|
|
5048
5113
|
const PROPOSAL_PREVIEW_COLLECTION: &str = "_feltdb_proposal_previews";
|
|
5114
|
+
const CONVERGENCE_COLLECTION: &str = "_feltdb_convergence";
|
|
5115
|
+
const INTENT_COLLECTION: &str = "_feltdb_intents";
|
|
5116
|
+
const INTENT_EVENT_COLLECTION: &str = "_feltdb_intent_events";
|
|
5049
5117
|
|
|
5050
|
-
fn
|
|
5051
|
-
|
|
5052
|
-
|
|
5053
|
-
|
|
5054
|
-
|
|
5055
|
-
|
|
5056
|
-
|
|
5118
|
+
fn require_intent_service(headers: &HeaderMap) -> Result<(), ApiError> {
|
|
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();
|
|
5130
|
+
if Sha256::digest(supplied.as_bytes()) != Sha256::digest(expected.as_bytes()) {
|
|
5131
|
+
return Err(inspection_error(
|
|
5132
|
+
StatusCode::FORBIDDEN,
|
|
5133
|
+
"forbidden",
|
|
5134
|
+
"invalid Intent Engine service credential",
|
|
5135
|
+
));
|
|
5136
|
+
}
|
|
5137
|
+
Ok(())
|
|
5138
|
+
}
|
|
5139
|
+
|
|
5140
|
+
async fn execute_intent(
|
|
5141
|
+
State(state): State<AppState>,
|
|
5142
|
+
Extension(principal): Extension<Principal>,
|
|
5143
|
+
headers: HeaderMap,
|
|
5144
|
+
Json(input): Json<ExecuteIntentRequest>,
|
|
5145
|
+
) -> Result<Json<Value>, ApiError> {
|
|
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
|
+
}
|
|
5219
|
+
Ok(Json(value))
|
|
5220
|
+
}
|
|
5221
|
+
|
|
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))
|
|
5301
|
+
}
|
|
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)))
|
|
5383
|
+
}
|
|
5384
|
+
|
|
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);
|
|
5057
5405
|
json!({
|
|
5058
5406
|
"application":{"application_id":revision.application_id,"environment":environment},
|
|
5059
5407
|
"contract_version":revision.revision_number,"contract_hash":contract_hash,"flow_hash":flow_hash,
|
|
@@ -5065,11 +5413,18 @@ fn proposal_contract_snapshot(
|
|
|
5065
5413
|
}
|
|
5066
5414
|
|
|
5067
5415
|
fn proposal_hash(value: &Value) -> String {
|
|
5068
|
-
format!(
|
|
5416
|
+
format!(
|
|
5417
|
+
"sha256:{:x}",
|
|
5418
|
+
Sha256::digest(serde_json::to_vec(value).unwrap_or_default())
|
|
5419
|
+
)
|
|
5069
5420
|
}
|
|
5070
5421
|
|
|
5071
5422
|
fn preview_ttl_seconds() -> i64 {
|
|
5072
|
-
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)
|
|
5073
5428
|
}
|
|
5074
5429
|
|
|
5075
5430
|
fn preview_artifact(state: &AppState, id: &str) -> Result<Value, ApiError> {
|
|
@@ -5078,7 +5433,10 @@ fn preview_artifact(state: &AppState, id: &str) -> Result<Value, ApiError> {
|
|
|
5078
5433
|
|
|
5079
5434
|
fn preview_available(value: &Value) -> bool {
|
|
5080
5435
|
value.get("status").and_then(Value::as_str) == Some("ready")
|
|
5081
|
-
&& value
|
|
5436
|
+
&& value
|
|
5437
|
+
.get("expires_at")
|
|
5438
|
+
.and_then(Value::as_i64)
|
|
5439
|
+
.is_some_and(|expires| expires > unix_seconds_i64())
|
|
5082
5440
|
}
|
|
5083
5441
|
|
|
5084
5442
|
fn proposal_event(
|
|
@@ -5115,15 +5473,33 @@ async fn create_proposal(
|
|
|
5115
5473
|
Json(input): Json<CreateProposalRequest>,
|
|
5116
5474
|
) -> Result<(StatusCode, Json<Value>), ApiError> {
|
|
5117
5475
|
if input.status.is_some() {
|
|
5118
|
-
return Err(inspection_error(
|
|
5476
|
+
return Err(inspection_error(
|
|
5477
|
+
StatusCode::BAD_REQUEST,
|
|
5478
|
+
"invalid_proposal",
|
|
5479
|
+
"proposal lifecycle state is server-controlled",
|
|
5480
|
+
));
|
|
5119
5481
|
}
|
|
5120
5482
|
if input.proposal_version.unwrap_or(1) != 1 {
|
|
5121
|
-
return Err(inspection_error(
|
|
5483
|
+
return Err(inspection_error(
|
|
5484
|
+
StatusCode::UNPROCESSABLE_ENTITY,
|
|
5485
|
+
"invalid_proposal",
|
|
5486
|
+
"proposal_version must be 1",
|
|
5487
|
+
));
|
|
5122
5488
|
}
|
|
5123
|
-
if input
|
|
5124
|
-
|
|
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))
|
|
5125
5497
|
{
|
|
5126
|
-
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
|
+
));
|
|
5127
5503
|
}
|
|
5128
5504
|
let tenant = application_scope(
|
|
5129
5505
|
&state,
|
|
@@ -5165,6 +5541,46 @@ async fn create_proposal(
|
|
|
5165
5541
|
"proposed_flow and base_flow_hash are required".into(),
|
|
5166
5542
|
));
|
|
5167
5543
|
}
|
|
5544
|
+
if input.metadata.get("created_from").and_then(Value::as_str) == Some("module-intent")
|
|
5545
|
+
&& input.understanding.is_none()
|
|
5546
|
+
{
|
|
5547
|
+
return Err(inspection_error(
|
|
5548
|
+
StatusCode::UNPROCESSABLE_ENTITY,
|
|
5549
|
+
"understanding_required",
|
|
5550
|
+
"module proposals require an evidence-backed understanding snapshot",
|
|
5551
|
+
));
|
|
5552
|
+
}
|
|
5553
|
+
if input.understanding.is_some()
|
|
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:")))
|
|
5562
|
+
{
|
|
5563
|
+
return Err(inspection_error(
|
|
5564
|
+
StatusCode::UNPROCESSABLE_ENTITY,
|
|
5565
|
+
"invalid_understanding",
|
|
5566
|
+
"understanding requires evidence_hash and understanding_hash fingerprints",
|
|
5567
|
+
));
|
|
5568
|
+
}
|
|
5569
|
+
if let Some(understanding) = input.understanding.as_ref() {
|
|
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()
|
|
5576
|
+
{
|
|
5577
|
+
return Err(inspection_error(
|
|
5578
|
+
StatusCode::UNPROCESSABLE_ENTITY,
|
|
5579
|
+
"invalid_understanding",
|
|
5580
|
+
"understanding fingerprints must match the Proposal fingerprints",
|
|
5581
|
+
));
|
|
5582
|
+
}
|
|
5583
|
+
}
|
|
5168
5584
|
let id = format!("p_{}", uuid::Uuid::new_v4().simple());
|
|
5169
5585
|
let now = unix_seconds_i64();
|
|
5170
5586
|
let mut value = json!({ "id":id, "application_id":input.application_id, "environment":input.environment,
|
|
@@ -5173,6 +5589,7 @@ async fn create_proposal(
|
|
|
5173
5589
|
"contract_diff":input.contract_diff.unwrap_or_else(||json!([])), "source_plan":input.source_plan.unwrap_or_else(||json!({"files":[]})), "summary":input.summary, "warnings":input.warnings,
|
|
5174
5590
|
"created_by":principal.key_id, "created_at":now, "expires_at":input.expires_at, "metadata":input.metadata,
|
|
5175
5591
|
"parent_proposal_id":input.parent_proposal_id,
|
|
5592
|
+
"understanding":input.understanding, "evidence_hash":input.evidence_hash, "understanding_hash":input.understanding_hash,
|
|
5176
5593
|
"base_module_versions": revision.manifest.modules.iter().filter_map(|module| Some(json!({
|
|
5177
5594
|
"id": module.get("id")?, "provider": module.get("provider")?, "version": module.get("version")?
|
|
5178
5595
|
}))).collect::<Vec<_>>(),
|
|
@@ -5197,35 +5614,92 @@ async fn list_proposals(
|
|
|
5197
5614
|
)?;
|
|
5198
5615
|
let limit = input.limit.unwrap_or(DEFAULT_INSPECTION_LIMIT);
|
|
5199
5616
|
if limit == 0 || limit > MAX_INSPECTION_LIMIT {
|
|
5200
|
-
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
|
+
));
|
|
5201
5622
|
}
|
|
5202
|
-
if input.status.as_ref().is_some_and(|status|
|
|
5203
|
-
|
|
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
|
+
));
|
|
5204
5640
|
}
|
|
5205
|
-
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
|
+
);
|
|
5206
5647
|
let state_version = state.db.sequence()?;
|
|
5207
|
-
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()?;
|
|
5208
5655
|
let mut accepted = Vec::new();
|
|
5209
5656
|
let mut exhausted = false;
|
|
5210
5657
|
while accepted.len() <= limit && !exhausted {
|
|
5211
|
-
let page = state
|
|
5212
|
-
|
|
5213
|
-
|
|
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
|
+
}
|
|
5214
5667
|
for row in page {
|
|
5215
5668
|
after = Some(row.key.clone());
|
|
5216
|
-
if !proposal_scope_matches(&row.value, &input.application_id, &input.environment) {
|
|
5217
|
-
|
|
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
|
+
}
|
|
5218
5677
|
let mut value = row.value;
|
|
5219
5678
|
redact_inspection_value(&mut value);
|
|
5220
5679
|
accepted.push((row.key, value));
|
|
5221
|
-
if accepted.len() > limit {
|
|
5680
|
+
if accepted.len() > limit {
|
|
5681
|
+
break;
|
|
5682
|
+
}
|
|
5222
5683
|
}
|
|
5223
5684
|
}
|
|
5224
5685
|
let has_more = accepted.len() > limit;
|
|
5225
|
-
let last_key = accepted
|
|
5226
|
-
|
|
5227
|
-
|
|
5228
|
-
|
|
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
|
+
))
|
|
5229
5703
|
}
|
|
5230
5704
|
async fn get_proposal(
|
|
5231
5705
|
State(state): State<AppState>,
|
|
@@ -5267,8 +5741,19 @@ async fn proposal_history(
|
|
|
5267
5741
|
.map(|row| row.value)
|
|
5268
5742
|
.filter(|value| value.get("proposal_id").and_then(Value::as_str) == Some(id.as_str()))
|
|
5269
5743
|
.collect();
|
|
5270
|
-
events.sort_by(|left, right|
|
|
5271
|
-
|
|
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
|
+
}
|
|
5272
5757
|
Ok(Json(json!({"events":events})))
|
|
5273
5758
|
}
|
|
5274
5759
|
fn move_proposal(
|
|
@@ -5318,33 +5803,102 @@ fn move_proposal(
|
|
|
5318
5803
|
}
|
|
5319
5804
|
|
|
5320
5805
|
fn proposal_is_expired(value: &Value) -> bool {
|
|
5321
|
-
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())
|
|
5322
5810
|
}
|
|
5323
5811
|
|
|
5324
5812
|
fn proposal_readiness_value(
|
|
5325
5813
|
state: &AppState,
|
|
5326
5814
|
tenant: &str,
|
|
5327
5815
|
value: &Value,
|
|
5816
|
+
current_evidence_hash: Option<&str>,
|
|
5328
5817
|
) -> Result<Value, ApiError> {
|
|
5329
|
-
let application_id = value
|
|
5330
|
-
|
|
5331
|
-
|
|
5332
|
-
|
|
5333
|
-
let
|
|
5334
|
-
|
|
5335
|
-
|
|
5336
|
-
|
|
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("");
|
|
5337
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<_>>();
|
|
5338
5870
|
let contract_changed = current_contract_hash != proposal_contract_hash;
|
|
5339
|
-
let flow_changed = current_flow_hash
|
|
5340
|
-
|
|
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
|
+
});
|
|
5341
5884
|
let expired = proposal_is_expired(value);
|
|
5342
5885
|
let mut blockers = Vec::new();
|
|
5343
|
-
if contract_changed {
|
|
5344
|
-
|
|
5345
|
-
|
|
5346
|
-
if
|
|
5347
|
-
|
|
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
|
+
}
|
|
5901
|
+
let stale = contract_changed || flow_changed || modules_changed || evidence_changed;
|
|
5348
5902
|
Ok(json!({
|
|
5349
5903
|
"status":value.get("status"), "readiness":if blockers.is_empty(){"ready"}else{"blocked"},
|
|
5350
5904
|
"blockers":blockers, "notes":[], "current_contract_hash":current_contract_hash,
|
|
@@ -5352,19 +5906,163 @@ fn proposal_readiness_value(
|
|
|
5352
5906
|
"proposal_flow_hash":proposal_flow_hash, "stale":stale, "expires_at":value.get("expires_at")
|
|
5353
5907
|
}))
|
|
5354
5908
|
}
|
|
5355
|
-
|
|
5356
|
-
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(
|
|
5357
6029
|
State(state): State<AppState>,
|
|
5358
6030
|
Extension(principal): Extension<Principal>,
|
|
5359
6031
|
Path(id): Path<String>,
|
|
5360
6032
|
Query(input): Query<ProposalScopeRequest>,
|
|
5361
6033
|
) -> Result<Json<Value>, ApiError> {
|
|
5362
|
-
|
|
5363
|
-
|
|
5364
|
-
|
|
5365
|
-
|
|
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
|
+
));
|
|
5366
6047
|
}
|
|
5367
|
-
|
|
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})))
|
|
5368
6066
|
}
|
|
5369
6067
|
async fn validate_proposal(
|
|
5370
6068
|
State(state): State<AppState>,
|
|
@@ -5501,7 +6199,13 @@ async fn validate_proposal(
|
|
|
5501
6199
|
value["proposed_contract"] = serde_json::to_value(&proposed.manifest)
|
|
5502
6200
|
.map_err(|e| ApiError(StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
|
5503
6201
|
}
|
|
5504
|
-
|
|
6202
|
+
if !value
|
|
6203
|
+
.pointer("/source_plan/files")
|
|
6204
|
+
.and_then(Value::as_array)
|
|
6205
|
+
.is_some_and(|files| !files.is_empty())
|
|
6206
|
+
{
|
|
6207
|
+
value["source_plan"] = json!({"files":[{"path":"feltdb.flow","operation":"modify"},{"path":"src/feltdb/generated-contract.ts","operation":"modify"}]});
|
|
6208
|
+
}
|
|
5505
6209
|
Ok(Json(move_proposal(
|
|
5506
6210
|
&state,
|
|
5507
6211
|
&id,
|
|
@@ -5526,7 +6230,8 @@ async fn preview_proposal(
|
|
|
5526
6230
|
if !proposal_scope_matches(&value, &input.application_id, &input.environment) {
|
|
5527
6231
|
return Err(ApiError(StatusCode::NOT_FOUND, "proposal not found".into()));
|
|
5528
6232
|
}
|
|
5529
|
-
let replacing_expired_preview =
|
|
6233
|
+
let replacing_expired_preview =
|
|
6234
|
+
value.get("status").and_then(Value::as_str) == Some("previewed");
|
|
5530
6235
|
if replacing_expired_preview {
|
|
5531
6236
|
if let Some(preview_id) = value.get("preview_id").and_then(Value::as_str) {
|
|
5532
6237
|
let mut artifact = preview_artifact(&state, preview_id)?;
|
|
@@ -5534,42 +6239,131 @@ async fn preview_proposal(
|
|
|
5534
6239
|
let mut proposal = value.clone();
|
|
5535
6240
|
redact_inspection_value(&mut proposal);
|
|
5536
6241
|
redact_inspection_value(&mut artifact);
|
|
5537
|
-
if let Some(object) = artifact.as_object_mut() {
|
|
6242
|
+
if let Some(object) = artifact.as_object_mut() {
|
|
6243
|
+
object.remove("namespace");
|
|
6244
|
+
}
|
|
5538
6245
|
artifact["proposal"] = proposal;
|
|
5539
6246
|
return Ok(Json(artifact));
|
|
5540
6247
|
}
|
|
5541
6248
|
}
|
|
5542
6249
|
}
|
|
5543
|
-
if !replacing_expired_preview
|
|
5544
|
-
|
|
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
|
+
));
|
|
5545
6258
|
}
|
|
5546
|
-
let proposed_manifest: ApplicationManifest =
|
|
5547
|
-
|
|
5548
|
-
|
|
5549
|
-
|
|
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
|
+
})?;
|
|
6296
|
+
let readiness = proposal_readiness_value(&state, &tenant, &value, None)?;
|
|
5550
6297
|
if readiness.get("readiness").and_then(Value::as_str) != Some("ready") {
|
|
5551
|
-
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
|
+
));
|
|
5552
6302
|
}
|
|
5553
6303
|
let preview_id = format!("pv_{}", uuid::Uuid::new_v4().simple());
|
|
5554
6304
|
let preview_environment = format!("preview/proposal/{preview_id}");
|
|
5555
|
-
let proposed_manifest_hash = manifest_hash(&proposed_manifest).map_err(|error|
|
|
5556
|
-
|
|
5557
|
-
|
|
5558
|
-
|
|
5559
|
-
|
|
5560
|
-
|
|
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
|
+
})?;
|
|
5561
6339
|
let current_contract = proposal_contract_snapshot(¤t_revision, &input.environment);
|
|
5562
6340
|
let mut proposed_contract = proposal_contract_snapshot(&proposed_revision, &input.environment);
|
|
5563
6341
|
proposed_contract["contract_hash"] = json!(proposal_hash(&proposed_contract));
|
|
5564
|
-
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
|
+
));
|
|
5565
6353
|
let namespace = runtime.environment.state_namespace.clone();
|
|
5566
6354
|
let mut seeded_records = Vec::new();
|
|
5567
6355
|
for collection in &proposed_revision.manifest.collections {
|
|
5568
|
-
if collection.name.starts_with("_feltdb") {
|
|
6356
|
+
if collection.name.starts_with("_feltdb") {
|
|
6357
|
+
continue;
|
|
6358
|
+
}
|
|
5569
6359
|
let record_id = format!("preview_{}", collection.name.to_ascii_lowercase());
|
|
5570
6360
|
let record = json!({"id":record_id,"_preview_data":true,"_preview_source":"synthetic","label":format!("Synthetic {} preview",collection.name)});
|
|
5571
|
-
state
|
|
5572
|
-
|
|
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}));
|
|
5573
6367
|
}
|
|
5574
6368
|
let now = unix_seconds_i64();
|
|
5575
6369
|
let mut artifact = json!({
|
|
@@ -5585,7 +6379,15 @@ async fn preview_proposal(
|
|
|
5585
6379
|
"seeded_records":seeded_records,"data_classification":"synthetic_preview","external_side_effects":"simulated","readiness":readiness
|
|
5586
6380
|
});
|
|
5587
6381
|
redact_inspection_value(&mut artifact);
|
|
5588
|
-
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
|
+
)?;
|
|
5589
6391
|
value["preview_id"] = artifact.get("preview_id").cloned().unwrap_or(Value::Null);
|
|
5590
6392
|
value["preview_expires_at"] = artifact.get("expires_at").cloned().unwrap_or(Value::Null);
|
|
5591
6393
|
let mut stored_proposal = if replacing_expired_preview {
|
|
@@ -5595,76 +6397,209 @@ async fn preview_proposal(
|
|
|
5595
6397
|
move_proposal(&state, &id, value, "previewed", &principal.key_id)?
|
|
5596
6398
|
};
|
|
5597
6399
|
redact_inspection_value(&mut stored_proposal);
|
|
5598
|
-
if let Some(object) = artifact.as_object_mut() {
|
|
6400
|
+
if let Some(object) = artifact.as_object_mut() {
|
|
6401
|
+
object.remove("namespace");
|
|
6402
|
+
}
|
|
5599
6403
|
artifact["proposal"] = stored_proposal;
|
|
5600
6404
|
Ok(Json(artifact))
|
|
5601
6405
|
}
|
|
5602
6406
|
|
|
5603
6407
|
async fn get_proposal_preview(
|
|
5604
|
-
State(state): State<AppState>,
|
|
6408
|
+
State(state): State<AppState>,
|
|
6409
|
+
Extension(principal): Extension<Principal>,
|
|
6410
|
+
Path(id): Path<String>,
|
|
5605
6411
|
Query(input): Query<ProposalScopeRequest>,
|
|
5606
6412
|
) -> Result<Json<Value>, ApiError> {
|
|
5607
|
-
let tenant = application_scope(
|
|
6413
|
+
let tenant = application_scope(
|
|
6414
|
+
&state,
|
|
6415
|
+
&principal.key_id,
|
|
6416
|
+
&input.application_id,
|
|
6417
|
+
"application:read",
|
|
6418
|
+
)?;
|
|
5608
6419
|
let mut artifact = preview_artifact(&state, &id)?;
|
|
5609
6420
|
if !proposal_scope_matches(&artifact, &input.application_id, &input.environment) {
|
|
5610
|
-
return Err(inspection_error(
|
|
6421
|
+
return Err(inspection_error(
|
|
6422
|
+
StatusCode::NOT_FOUND,
|
|
6423
|
+
"preview_not_found",
|
|
6424
|
+
"preview not found",
|
|
6425
|
+
));
|
|
5611
6426
|
}
|
|
5612
6427
|
if !preview_available(&artifact) {
|
|
5613
|
-
return Err(inspection_error(
|
|
6428
|
+
return Err(inspection_error(
|
|
6429
|
+
StatusCode::GONE,
|
|
6430
|
+
"preview_expired",
|
|
6431
|
+
"preview artifact has expired",
|
|
6432
|
+
));
|
|
5614
6433
|
}
|
|
5615
|
-
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
|
+
)?;
|
|
5616
6441
|
if proposal.get("preview_id") != artifact.get("preview_id") {
|
|
5617
|
-
return Err(inspection_error(
|
|
6442
|
+
return Err(inspection_error(
|
|
6443
|
+
StatusCode::CONFLICT,
|
|
6444
|
+
"preview_invalidated",
|
|
6445
|
+
"preview has been replaced",
|
|
6446
|
+
));
|
|
5618
6447
|
}
|
|
5619
|
-
if proposal_readiness_value(&state, &tenant, &proposal
|
|
5620
|
-
|
|
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
|
+
));
|
|
5621
6458
|
}
|
|
5622
6459
|
redact_inspection_value(&mut artifact);
|
|
5623
|
-
if let Some(object) = artifact.as_object_mut() {
|
|
6460
|
+
if let Some(object) = artifact.as_object_mut() {
|
|
6461
|
+
object.remove("namespace");
|
|
6462
|
+
}
|
|
5624
6463
|
Ok(Json(artifact))
|
|
5625
6464
|
}
|
|
5626
6465
|
|
|
5627
6466
|
async fn inspect_proposal_preview_data(
|
|
5628
|
-
State(state): State<AppState>,
|
|
6467
|
+
State(state): State<AppState>,
|
|
6468
|
+
Extension(principal): Extension<Principal>,
|
|
6469
|
+
Path((id, collection)): Path<(String, String)>,
|
|
5629
6470
|
Query(input): Query<ProposalScopeRequest>,
|
|
5630
6471
|
) -> Result<Json<Value>, ApiError> {
|
|
5631
|
-
application_scope(
|
|
6472
|
+
application_scope(
|
|
6473
|
+
&state,
|
|
6474
|
+
&principal.key_id,
|
|
6475
|
+
&input.application_id,
|
|
6476
|
+
"application:read",
|
|
6477
|
+
)?;
|
|
5632
6478
|
let artifact = preview_artifact(&state, &id)?;
|
|
5633
6479
|
if !proposal_scope_matches(&artifact, &input.application_id, &input.environment) {
|
|
5634
|
-
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
|
+
));
|
|
5635
6492
|
}
|
|
5636
|
-
if
|
|
5637
|
-
|
|
5638
|
-
|
|
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
|
+
));
|
|
5639
6508
|
}
|
|
5640
6509
|
let limit = input.limit.unwrap_or(DEFAULT_INSPECTION_LIMIT);
|
|
5641
|
-
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
|
+
}
|
|
5642
6517
|
let state_version = state.db.sequence()?;
|
|
5643
6518
|
let filter_hash = format!("{id}:{collection}");
|
|
5644
|
-
let after = input
|
|
5645
|
-
|
|
5646
|
-
|
|
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))?;
|
|
5647
6536
|
let has_more = rows.len() > limit;
|
|
5648
6537
|
rows.truncate(limit);
|
|
5649
6538
|
let last_key = rows.last().map(|row| row.key.clone());
|
|
5650
6539
|
let mut records = rows.into_iter().map(|row| row.value).collect::<Vec<_>>();
|
|
5651
|
-
for record in &mut records {
|
|
5652
|
-
|
|
5653
|
-
|
|
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
|
+
))
|
|
5654
6551
|
}
|
|
5655
6552
|
|
|
5656
6553
|
async fn simulate_proposal_preview(
|
|
5657
|
-
State(state): State<AppState>,
|
|
5658
|
-
|
|
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>,
|
|
5659
6559
|
) -> Result<Json<Value>, ApiError> {
|
|
5660
|
-
application_scope(
|
|
6560
|
+
application_scope(
|
|
6561
|
+
&state,
|
|
6562
|
+
&principal.key_id,
|
|
6563
|
+
&input.application_id,
|
|
6564
|
+
"application:write",
|
|
6565
|
+
)?;
|
|
5661
6566
|
let artifact = preview_artifact(&state, &id)?;
|
|
5662
|
-
if !proposal_scope_matches(&artifact, &input.application_id, &input.environment) {
|
|
5663
|
-
|
|
5664
|
-
|
|
5665
|
-
|
|
5666
|
-
|
|
5667
|
-
|
|
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
|
+
))
|
|
5668
6603
|
}
|
|
5669
6604
|
async fn transition_proposal(
|
|
5670
6605
|
State(state): State<AppState>,
|
|
@@ -5693,29 +6628,70 @@ async fn transition_proposal(
|
|
|
5693
6628
|
));
|
|
5694
6629
|
}
|
|
5695
6630
|
if proposal_is_expired(&value) && input.status != "expired" {
|
|
5696
|
-
return Err(inspection_error(
|
|
6631
|
+
return Err(inspection_error(
|
|
6632
|
+
StatusCode::CONFLICT,
|
|
6633
|
+
"proposal_expired",
|
|
6634
|
+
"proposal has expired",
|
|
6635
|
+
));
|
|
5697
6636
|
}
|
|
5698
6637
|
if input.status == "approved" {
|
|
5699
|
-
let readiness = proposal_readiness_value(&state, &tenant, &value)?;
|
|
6638
|
+
let readiness = proposal_readiness_value(&state, &tenant, &value, None)?;
|
|
5700
6639
|
if readiness.get("readiness").and_then(Value::as_str) != Some("ready") {
|
|
5701
|
-
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
|
+
));
|
|
5702
6644
|
}
|
|
5703
|
-
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
|
+
})?;
|
|
5704
6655
|
let artifact = preview_artifact(&state, preview_id)?;
|
|
5705
6656
|
let bound = preview_available(&artifact)
|
|
5706
6657
|
&& artifact.get("proposal_id").and_then(Value::as_str) == Some(id.as_str())
|
|
5707
6658
|
&& artifact.get("proposal_contract_hash") == value.get("base_contract_hash")
|
|
5708
6659
|
&& artifact.get("proposal_flow_hash") == value.get("base_flow_hash")
|
|
5709
6660
|
&& artifact.get("module_versions") == value.get("module_versions");
|
|
5710
|
-
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
|
+
}
|
|
5711
6668
|
}
|
|
5712
6669
|
if input.status == "applied" {
|
|
5713
6670
|
let configured = std::env::var("FELTDB_PROPOSAL_AUTHORITY_TOKEN").ok();
|
|
5714
|
-
let supplied = headers
|
|
5715
|
-
|
|
5716
|
-
|
|
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
|
+
});
|
|
5717
6689
|
if !repository_apply || !authorized {
|
|
5718
|
-
return Err(inspection_error(
|
|
6690
|
+
return Err(inspection_error(
|
|
6691
|
+
StatusCode::FORBIDDEN,
|
|
6692
|
+
"repository_authority_required",
|
|
6693
|
+
"valid repository application authority is required",
|
|
6694
|
+
));
|
|
5719
6695
|
}
|
|
5720
6696
|
}
|
|
5721
6697
|
if input.status == "approved"
|
|
@@ -5736,37 +6712,59 @@ async fn transition_proposal(
|
|
|
5736
6712
|
));
|
|
5737
6713
|
}
|
|
5738
6714
|
if input.status == "rejected" {
|
|
5739
|
-
if let Some(reason) = input.reason {
|
|
6715
|
+
if let Some(reason) = input.reason {
|
|
6716
|
+
value["rejection_reason"] = json!(reason);
|
|
6717
|
+
}
|
|
5740
6718
|
}
|
|
5741
|
-
let mut moved = move_proposal(
|
|
5742
|
-
&state,
|
|
5743
|
-
&id,
|
|
5744
|
-
value,
|
|
5745
|
-
&input.status,
|
|
5746
|
-
&principal.key_id,
|
|
5747
|
-
)?;
|
|
6719
|
+
let mut moved = move_proposal(&state, &id, value, &input.status, &principal.key_id)?;
|
|
5748
6720
|
redact_inspection_value(&mut moved);
|
|
5749
6721
|
Ok(Json(moved))
|
|
5750
6722
|
}
|
|
5751
6723
|
|
|
5752
6724
|
async fn approve_proposal(
|
|
5753
|
-
state: State<AppState>,
|
|
5754
|
-
|
|
6725
|
+
state: State<AppState>,
|
|
6726
|
+
principal: Extension<Principal>,
|
|
6727
|
+
headers: HeaderMap,
|
|
6728
|
+
path: Path<String>,
|
|
6729
|
+
Json(input): Json<ProposalLifecycleRequest>,
|
|
5755
6730
|
) -> Result<Json<Value>, ApiError> {
|
|
5756
|
-
transition_proposal(
|
|
5757
|
-
|
|
5758
|
-
|
|
5759
|
-
|
|
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
|
|
5760
6745
|
}
|
|
5761
6746
|
|
|
5762
6747
|
async fn reject_proposal(
|
|
5763
|
-
state: State<AppState>,
|
|
5764
|
-
|
|
6748
|
+
state: State<AppState>,
|
|
6749
|
+
principal: Extension<Principal>,
|
|
6750
|
+
headers: HeaderMap,
|
|
6751
|
+
path: Path<String>,
|
|
6752
|
+
Json(input): Json<ProposalLifecycleRequest>,
|
|
5765
6753
|
) -> Result<Json<Value>, ApiError> {
|
|
5766
|
-
transition_proposal(
|
|
5767
|
-
|
|
5768
|
-
|
|
5769
|
-
|
|
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
|
|
5770
6768
|
}
|
|
5771
6769
|
fn auth_response(
|
|
5772
6770
|
identity: feltdb_server::identity::Identity,
|
|
@@ -5795,19 +6793,18 @@ async fn auth_sign_up(
|
|
|
5795
6793
|
"application not found".into(),
|
|
5796
6794
|
));
|
|
5797
6795
|
}
|
|
5798
|
-
let (identity, session, token) =
|
|
5799
|
-
|
|
5800
|
-
|
|
5801
|
-
|
|
5802
|
-
|
|
5803
|
-
|
|
5804
|
-
|
|
5805
|
-
|
|
5806
|
-
|
|
5807
|
-
|
|
5808
|
-
|
|
5809
|
-
|
|
5810
|
-
})?;
|
|
6796
|
+
let (identity, session, token) =
|
|
6797
|
+
match state
|
|
6798
|
+
.identities
|
|
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
|
+
};
|
|
5811
6808
|
state
|
|
5812
6809
|
.tenancy
|
|
5813
6810
|
.register_platform_user(
|
|
@@ -5829,6 +6826,25 @@ async fn auth_sign_in(
|
|
|
5829
6826
|
.identities
|
|
5830
6827
|
.sign_in(&input.email, &input.password)
|
|
5831
6828
|
.map_err(|_| ApiError(StatusCode::UNAUTHORIZED, "invalid credentials".into()))?;
|
|
6829
|
+
if let Some(application_id) = input.application_id {
|
|
6830
|
+
if state.tenancy.application_tenant(&application_id).is_none() {
|
|
6831
|
+
return Err(ApiError(
|
|
6832
|
+
StatusCode::NOT_FOUND,
|
|
6833
|
+
"application not found".into(),
|
|
6834
|
+
));
|
|
6835
|
+
}
|
|
6836
|
+
state
|
|
6837
|
+
.tenancy
|
|
6838
|
+
.register_platform_user(
|
|
6839
|
+
&identity.subject_id,
|
|
6840
|
+
identity.email.as_deref().unwrap_or(&identity.identity_id),
|
|
6841
|
+
)
|
|
6842
|
+
.map_err(control_error)?;
|
|
6843
|
+
state
|
|
6844
|
+
.tenancy
|
|
6845
|
+
.register_application_actor(&identity.subject_id, &application_id)
|
|
6846
|
+
.map_err(control_error)?;
|
|
6847
|
+
}
|
|
5832
6848
|
Ok(auth_response(identity, session, token))
|
|
5833
6849
|
}
|
|
5834
6850
|
async fn auth_session(Extension(principal): Extension<Principal>) -> Json<Value> {
|
|
@@ -5872,6 +6888,13 @@ async fn application_openapi() -> Json<Value> {
|
|
|
5872
6888
|
"/query": { "post": { "responses": { "200": { "description": "Bounded authorized query" } } } },
|
|
5873
6889
|
"/transactions": { "post": { "responses": { "200": { "description": "Atomic idempotent mutation" } } } },
|
|
5874
6890
|
"/events": { "get": { "responses": { "200": { "description": "Application event stream" } } } },
|
|
6891
|
+
"/intents": {
|
|
6892
|
+
"get": { "responses": { "200": { "description": "Durable application Intents" } } },
|
|
6893
|
+
"post": { "parameters": [{ "name":"Idempotency-Key", "in":"header", "required":false, "schema":{"type":"string"} }], "responses": { "200": { "description": "Canonical Intent execution result" } } }
|
|
6894
|
+
},
|
|
6895
|
+
"/intents/{id}": { "get": { "responses": { "200": { "description": "Canonical Intent" } } } },
|
|
6896
|
+
"/intents/{id}/{artifact}": { "get": { "responses": { "200": { "description": "Intent understanding, plan, Proposal, or history" } } } },
|
|
6897
|
+
"/proposals": { "get": { "responses": { "200": { "description": "Durable Proposals" } } }, "post": { "responses": { "201": { "description": "Created Proposal" } } } },
|
|
5875
6898
|
"/workflows/{name}/run": { "post": { "responses": { "201": { "description": "Workflow execution" } } } },
|
|
5876
6899
|
"/agents/{name}/run": { "post": { "responses": { "201": { "description": "Agent execution" } } } },
|
|
5877
6900
|
"/capabilities/{name}/run": { "post": { "responses": { "200": { "description": "Public capability result" } } } }
|
|
@@ -7199,7 +8222,9 @@ fn validate_production_configuration(config: &Config) -> Result<(), Box<dyn std:
|
|
|
7199
8222
|
Ok(())
|
|
7200
8223
|
}
|
|
7201
8224
|
|
|
7202
|
-
|
|
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)]
|
|
7203
8228
|
async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
|
7204
8229
|
tracing_subscriber::fmt()
|
|
7205
8230
|
.with_env_filter(tracing_subscriber::EnvFilter::from_default_env())
|
|
@@ -7394,6 +8419,10 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
|
|
7394
8419
|
.patch(update_application_control)
|
|
7395
8420
|
.delete(delete_application_control),
|
|
7396
8421
|
)
|
|
8422
|
+
.route(
|
|
8423
|
+
"/api/applications/{application_id}/qualification/destroy",
|
|
8424
|
+
axum::routing::post(delete_qualification_application_control),
|
|
8425
|
+
)
|
|
7397
8426
|
.route(
|
|
7398
8427
|
"/api/applications/{application_id}/members",
|
|
7399
8428
|
get(list_application_members),
|
|
@@ -7547,6 +8576,9 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
|
|
7547
8576
|
.route("/v1/application/schema", get(inspect_application_schema))
|
|
7548
8577
|
.route("/v1/data/{collection}", get(inspect_collection))
|
|
7549
8578
|
.route("/v1/data/{collection}/{id}", get(inspect_record))
|
|
8579
|
+
.route("/v1/intents", get(list_intents).post(execute_intent))
|
|
8580
|
+
.route("/v1/intents/{id}", get(get_intent_root))
|
|
8581
|
+
.route("/v1/intents/{id}/{operation}", get(get_intent))
|
|
7550
8582
|
.route("/v1/proposals", get(list_proposals).post(create_proposal))
|
|
7551
8583
|
.route("/v1/proposals/{id}", get(get_proposal))
|
|
7552
8584
|
.route("/v1/proposals/{id}/history", get(proposal_history))
|
|
@@ -7570,9 +8602,19 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
|
|
7570
8602
|
"/v1/proposals/{id}/status",
|
|
7571
8603
|
get(proposal_status).patch(transition_proposal),
|
|
7572
8604
|
)
|
|
8605
|
+
.route(
|
|
8606
|
+
"/v1/proposals/{id}/convergence",
|
|
8607
|
+
get(proposal_convergence).post(record_proposal_convergence),
|
|
8608
|
+
)
|
|
7573
8609
|
.route("/v1/previews/{id}", get(get_proposal_preview))
|
|
7574
|
-
.route(
|
|
7575
|
-
|
|
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
|
+
)
|
|
7576
8618
|
.route("/v1/auth/session", get(auth_session))
|
|
7577
8619
|
.route("/v1/auth/signout", axum::routing::post(auth_sign_out))
|
|
7578
8620
|
.route("/v1/events", get(events))
|
|
@@ -7956,6 +8998,16 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
|
|
7956
8998
|
.route("/v1/openapi.json", get(application_openapi))
|
|
7957
8999
|
.route("/v1/auth/signup", axum::routing::post(auth_sign_up))
|
|
7958
9000
|
.route("/v1/auth/signin", axum::routing::post(auth_sign_in))
|
|
9001
|
+
.route("/internal/intents/by-hash", get(internal_find_intent))
|
|
9002
|
+
.route(
|
|
9003
|
+
"/internal/intents",
|
|
9004
|
+
axum::routing::post(internal_create_intent),
|
|
9005
|
+
)
|
|
9006
|
+
.route("/internal/intents/{id}", put(internal_replace_intent))
|
|
9007
|
+
.route(
|
|
9008
|
+
"/internal/intent-events",
|
|
9009
|
+
axum::routing::post(internal_create_intent_event),
|
|
9010
|
+
)
|
|
7959
9011
|
.route("/ready", get(readiness))
|
|
7960
9012
|
.route("/runtime", get(runtime))
|
|
7961
9013
|
.route("/metrics", get(network_metrics))
|
|
@@ -8036,7 +9088,14 @@ async fn authenticate(
|
|
|
8036
9088
|
.and_then(|header| header.to_str().ok())
|
|
8037
9089
|
.and_then(|header| header.strip_prefix("Bearer "));
|
|
8038
9090
|
let machine = token.and_then(|token| state.keys.authenticate(token, &state.namespace));
|
|
8039
|
-
|
|
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
|
+
};
|
|
8040
9099
|
let human = request
|
|
8041
9100
|
.headers()
|
|
8042
9101
|
.get(COOKIE)
|
|
@@ -10637,10 +11696,9 @@ fn put_canonical(
|
|
|
10637
11696
|
|
|
10638
11697
|
/// The authority's current committed-state revision.
|
|
10639
11698
|
///
|
|
10640
|
-
/// This is the value `apply_atomic_transaction` advances
|
|
10641
|
-
///
|
|
10642
|
-
///
|
|
10643
|
-
/// 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.
|
|
10644
11702
|
///
|
|
10645
11703
|
/// `scope` is the store's instance id. A revision is comparable only against
|
|
10646
11704
|
/// another revision carrying the same scope, so a client that fails over to a
|
|
@@ -10654,7 +11712,7 @@ struct RevisionResponse {
|
|
|
10654
11712
|
|
|
10655
11713
|
async fn get_revision(State(state): State<AppState>) -> Result<Json<RevisionResponse>, ApiError> {
|
|
10656
11714
|
Ok(Json(RevisionResponse {
|
|
10657
|
-
revision: state.db.
|
|
11715
|
+
revision: state.db.current_revision()?,
|
|
10658
11716
|
scope: state.db.instance_id()?,
|
|
10659
11717
|
}))
|
|
10660
11718
|
}
|
|
@@ -10937,7 +11995,12 @@ async fn list_records(
|
|
|
10937
11995
|
State(state): State<AppState>,
|
|
10938
11996
|
Path(collection): Path<String>,
|
|
10939
11997
|
) -> Result<Json<Vec<RecordResponse>>, ApiError> {
|
|
10940
|
-
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
|
+
{
|
|
10941
12004
|
return Err(ApiError(
|
|
10942
12005
|
StatusCode::FORBIDDEN,
|
|
10943
12006
|
"system collection requires proposal service".into(),
|
|
@@ -10952,7 +12015,12 @@ async fn get_record(
|
|
|
10952
12015
|
State(state): State<AppState>,
|
|
10953
12016
|
Path((collection, id)): Path<(String, String)>,
|
|
10954
12017
|
) -> Result<Json<RecordResponse>, ApiError> {
|
|
10955
|
-
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
|
+
{
|
|
10956
12024
|
return Err(ApiError(
|
|
10957
12025
|
StatusCode::FORBIDDEN,
|
|
10958
12026
|
"system collection requires proposal service".into(),
|
|
@@ -11034,6 +12102,9 @@ struct AtomicTxRequest {
|
|
|
11034
12102
|
#[serde(rename_all = "camelCase")]
|
|
11035
12103
|
struct AtomicTxResponse {
|
|
11036
12104
|
transaction_id: String,
|
|
12105
|
+
base_revision: u64,
|
|
12106
|
+
commit_revision: u64,
|
|
12107
|
+
status: feltdb::TransactionStatus,
|
|
11037
12108
|
/// True when this id had already been committed; nothing was reapplied.
|
|
11038
12109
|
duplicate: bool,
|
|
11039
12110
|
operations: usize,
|
|
@@ -11185,6 +12256,9 @@ async fn commit_transaction(
|
|
|
11185
12256
|
},
|
|
11186
12257
|
Json(AtomicTxResponse {
|
|
11187
12258
|
transaction_id: commit.transaction_id,
|
|
12259
|
+
base_revision: commit.base_revision,
|
|
12260
|
+
commit_revision: commit.commit_revision,
|
|
12261
|
+
status: commit.status,
|
|
11188
12262
|
duplicate: commit.duplicate,
|
|
11189
12263
|
operations,
|
|
11190
12264
|
state_before: commit.state_before,
|
|
@@ -11198,7 +12272,12 @@ async fn create_record(
|
|
|
11198
12272
|
Path(collection): Path<String>,
|
|
11199
12273
|
Json(mut record): Json<CreateRecord>,
|
|
11200
12274
|
) -> Result<(StatusCode, Json<RecordResponse>), ApiError> {
|
|
11201
|
-
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
|
+
{
|
|
11202
12281
|
return Err(ApiError(
|
|
11203
12282
|
StatusCode::FORBIDDEN,
|
|
11204
12283
|
"system collection requires proposal service".into(),
|
|
@@ -11237,7 +12316,12 @@ async fn update_record(
|
|
|
11237
12316
|
Path((collection, id)): Path<(String, String)>,
|
|
11238
12317
|
Json(changes): Json<Map<String, Value>>,
|
|
11239
12318
|
) -> Result<Json<RecordResponse>, ApiError> {
|
|
11240
|
-
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
|
+
{
|
|
11241
12325
|
return Err(ApiError(
|
|
11242
12326
|
StatusCode::FORBIDDEN,
|
|
11243
12327
|
"system collection requires proposal service".into(),
|
|
@@ -11358,7 +12442,12 @@ async fn delete_record(
|
|
|
11358
12442
|
State(state): State<AppState>,
|
|
11359
12443
|
Path((collection, id)): Path<(String, String)>,
|
|
11360
12444
|
) -> Result<StatusCode, ApiError> {
|
|
11361
|
-
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
|
+
{
|
|
11362
12451
|
return Err(ApiError(
|
|
11363
12452
|
StatusCode::FORBIDDEN,
|
|
11364
12453
|
"system collection requires proposal service".into(),
|