@feltdb/core 0.7.4 → 0.8.1
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/README.md +25 -0
- package/dist/application-development.d.ts +10 -3
- package/dist/application-development.d.ts.map +1 -1
- package/dist/application-development.js +11 -1
- package/dist/application-manifest.d.ts +2 -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/canonical-application.d.ts +1 -0
- package/dist/canonical-application.d.ts.map +1 -1
- package/dist/canonical-application.js +3 -2
- package/dist/cli/ai-model-config.js +3 -3
- package/dist/cli/cli.js +1 -1
- package/dist/cli/commands.js +321 -45
- package/dist/cli/index.js +1 -1
- package/dist/cli/managed-environment.js +6 -0
- package/dist/cli/managed-project.js +6 -0
- package/dist/cli/proposal-client.js +16 -3
- package/dist/cli/publish-engine.js +8 -5
- package/dist/cli/repository-evidence.js +55 -0
- package/dist/cli/source-sync.js +220 -61
- package/dist/convergence.d.ts +55 -0
- package/dist/convergence.d.ts.map +1 -0
- package/dist/convergence.js +63 -0
- package/dist/create/cli.js +10 -1
- package/dist/create/create.js +10 -12
- package/dist/create/managed-account.js +72 -7
- package/dist/create/package-versions.js +1 -1
- package/dist/create/server-source/crates/feltdb/src/application.rs +14 -0
- package/dist/create/server-source/crates/feltdb/src/lib.rs +20 -2
- package/dist/create/server-source/crates/feltdb-server/src/app_state.rs +12 -24
- package/dist/create/server-source/crates/feltdb-server/src/application_contract.rs +11 -3
- package/dist/create/server-source/crates/feltdb-server/src/authenticated_principal.rs +0 -1
- package/dist/create/server-source/crates/feltdb-server/src/certification_harness.rs +23 -22
- package/dist/create/server-source/crates/feltdb-server/src/connections.rs +14 -0
- package/dist/create/server-source/crates/feltdb-server/src/delegation_token.rs +5 -15
- package/dist/create/server-source/crates/feltdb-server/src/durable_operations.rs +13 -28
- package/dist/create/server-source/crates/feltdb-server/src/identity.rs +79 -14
- package/dist/create/server-source/crates/feltdb-server/src/key_management.rs +28 -7
- package/dist/create/server-source/crates/feltdb-server/src/lib.rs +5 -5
- package/dist/create/server-source/crates/feltdb-server/src/main.rs +1929 -261
- package/dist/create/server-source/crates/feltdb-server/src/managed_diagnostics.rs +10 -30
- package/dist/create/server-source/crates/feltdb-server/src/membership_policy.rs +12 -4
- package/dist/create/server-source/crates/feltdb-server/src/request_telemetry.rs +8 -6
- package/dist/create/server-source/crates/feltdb-server/src/snapshot_cursor.rs +10 -14
- package/dist/create/server-source/crates/feltdb-server/src/tenancy.rs +191 -22
- package/dist/create/server-source/crates/feltdb-server/src/tenant_policies.rs +9 -21
- package/dist/create/server-source/crates/feltdb-server/src/transaction_idempotency.rs +5 -3
- package/dist/create/server-source/crates/feltdb-server/src/transaction_recovery.rs +9 -8
- package/dist/create/server-source/crates/feltdb-server/tests/revision_recovery_integration_test.rs +1 -4
- package/dist/create/template/default-project/feltdb.flow +5 -0
- 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 +1 -0
- package/dist/flowspec.d.ts.map +1 -1
- package/dist/flowspec.js +51 -12
- package/dist/http-client.d.ts +166 -14
- package/dist/http-client.d.ts.map +1 -1
- package/dist/http-client.js +91 -31
- package/dist/http-db.d.ts +3 -0
- package/dist/http-db.d.ts.map +1 -1
- package/dist/http-db.js +28 -3
- package/dist/index-core.d.ts +5 -0
- package/dist/index-core.d.ts.map +1 -1
- package/dist/index-core.js +5 -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 +51 -0
- package/dist/module-intent.d.ts.map +1 -0
- package/dist/module-intent.js +230 -0
- package/dist/module.d.ts +105 -0
- package/dist/module.d.ts.map +1 -0
- package/dist/module.js +566 -0
- package/dist/proposal.d.ts +92 -4
- package/dist/proposal.d.ts.map +1 -1
- package/dist/proposal.js +150 -6
- package/dist/studio/app.d.ts +3 -1
- package/dist/studio/app.d.ts.map +1 -1
- package/dist/studio/components/AskFeltDB.d.ts +2 -1
- package/dist/studio/components/AskFeltDB.d.ts.map +1 -1
- package/dist/studio/components/ContractInspector.d.ts +7 -0
- package/dist/studio/components/ContractInspector.d.ts.map +1 -0
- package/dist/studio/components/ManagedInstancePanel.d.ts.map +1 -1
- package/dist/studio/components/ManagedInstancePanel.js +1 -5
- package/dist/studio/components/ModuleExplorer.d.ts +6 -0
- package/dist/studio/components/ModuleExplorer.d.ts.map +1 -0
- package/dist/studio/components/ProposalBrowser.d.ts +10 -1
- package/dist/studio/components/ProposalBrowser.d.ts.map +1 -1
- package/dist/studio/components/StateExplorer.d.ts +6 -7
- package/dist/studio/components/StateExplorer.d.ts.map +1 -1
- package/dist/studio/components/index.d.ts +2 -0
- package/dist/studio/components/index.d.ts.map +1 -1
- package/dist/studio/components/index.js +4 -4
- package/dist/studio/components-Duq2xQWt.js +2845 -0
- package/dist/studio/index.js +193 -147
- package/dist/studio/studio.css +1 -1
- package/dist/studio/utils/index.d.ts +2 -0
- package/dist/studio/utils/index.d.ts.map +1 -1
- package/dist/studio/utils/index.js +14 -12
- package/dist/studio/utils/managed-instance.d.ts +0 -4
- package/dist/studio/utils/managed-instance.d.ts.map +1 -1
- package/dist/studio/utils/managed-instance.js +0 -4
- package/dist/studio/utils/proposal-api.d.ts +56 -0
- package/dist/studio/utils/proposal-api.d.ts.map +1 -0
- package/dist/studio/utils/proposal-api.js +94 -0
- package/dist/studio/utils/service-api.d.ts +81 -0
- package/dist/studio/utils/service-api.d.ts.map +1 -0
- package/dist/studio/utils/service-api.js +51 -0
- package/dist/studio-app/assets/{feltdb_wasm-B-Ccgccb.js → feltdb_wasm-bIqcRzAr.js} +1 -1
- package/dist/studio-app/assets/feltdb_wasm_bg-BE79okwX.wasm +0 -0
- package/dist/studio-app/assets/{index-BRrmvaKw.css → index-Cd_NPw14.css} +1 -1
- package/dist/studio-app/assets/index-XGYdlElN.js +29 -0
- 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 +128 -4
- package/package.json +1 -1
- package/dist/studio/components-Bm4sjhcX.js +0 -2162
- package/dist/studio-app/assets/dist-DA1MYSzp.js +0 -1
- package/dist/studio-app/assets/feltdb_wasm_bg-wH3enJIA.wasm +0 -0
- package/dist/studio-app/assets/index-CEIqowCh.js +0 -42
- package/dist/studio-app/assets/lib-B2_7fcn7.js +0 -69
- package/dist/studio-app/assets/worker-CARZ5g7K.js +0 -69
|
@@ -30,8 +30,8 @@ use axum::{
|
|
|
30
30
|
use base64::Engine;
|
|
31
31
|
use feltdb::{
|
|
32
32
|
application::{
|
|
33
|
-
diff_manifests, validate_manifest, ActionOperation, ApplicationManifest,
|
|
34
|
-
ApplicationStore, PreviewDataMode, PreviewPolicy, RevisionStatus,
|
|
33
|
+
diff_manifests, manifest_hash, validate_manifest, ActionOperation, ApplicationManifest,
|
|
34
|
+
ApplicationRevision, ApplicationStore, PreviewDataMode, PreviewPolicy, RevisionStatus,
|
|
35
35
|
},
|
|
36
36
|
application_runtime::{
|
|
37
37
|
resolve_runtime, runtime_readiness, snapshot_state_namespace, validate_runtime,
|
|
@@ -42,12 +42,15 @@ use feltdb::{
|
|
|
42
42
|
Grant, GrantSigner, GrantStore, Subject as GrantSubject,
|
|
43
43
|
},
|
|
44
44
|
cardinality_endpoint::{CardinalityContext, CardinalityDiagnosticResponse},
|
|
45
|
-
|
|
46
|
-
|
|
45
|
+
policy_evaluation::{
|
|
46
|
+
Actor, AuthorizationState, PolicyContext, PolicyEvaluator, PolicySubject,
|
|
47
|
+
RecordAuthorizationContext,
|
|
48
|
+
},
|
|
47
49
|
state_contract::{
|
|
48
50
|
begin_read, compare_schemas, execute_query as execute_state_query, execute_transaction,
|
|
49
|
-
execute_transaction_with_collection_policies, schema_from_revision, validate_schema,
|
|
50
|
-
|
|
51
|
+
execute_transaction_with_collection_policies, schema_from_revision, validate_schema,
|
|
52
|
+
AuthorizationContext, CanonicalQuery, QueryFilter, StateSchema, TransactionOperation,
|
|
53
|
+
TransactionOperationKind, TransactionRequest,
|
|
51
54
|
},
|
|
52
55
|
sync_contract::{
|
|
53
56
|
DeviceIdentity, ReconciliationOutcome, SyncAck, SyncBatch, SyncConflict, SyncCursor,
|
|
@@ -58,7 +61,8 @@ use feltdb::{
|
|
|
58
61
|
WorkerRegistration,
|
|
59
62
|
},
|
|
60
63
|
workload::{CreateWorkload, WorkloadStore},
|
|
61
|
-
DatabaseSnapshot, FeltDb, JsonCasResult, Operation,
|
|
64
|
+
AtomicMutation, DatabaseSnapshot, FeltDb, FlowError, JsonCasResult, Operation,
|
|
65
|
+
PeerAdvertisement, PeerId, RecordPrecondition, StoredRow,
|
|
62
66
|
};
|
|
63
67
|
use feltdb_server::{
|
|
64
68
|
app_state::{AppState, BoundedQueryCursor},
|
|
@@ -110,7 +114,10 @@ impl ApiError {
|
|
|
110
114
|
/// `api_error_body` already parses a message that happens to be JSON, so
|
|
111
115
|
/// this is the existing mechanism named rather than a second one.
|
|
112
116
|
fn structured(status: StatusCode, body: Value) -> Self {
|
|
113
|
-
Self(
|
|
117
|
+
Self(
|
|
118
|
+
status,
|
|
119
|
+
serde_json::to_string(&body).unwrap_or_else(|_| body.to_string()),
|
|
120
|
+
)
|
|
114
121
|
}
|
|
115
122
|
}
|
|
116
123
|
|
|
@@ -134,13 +141,30 @@ fn api_error_body(status: StatusCode, message: String) -> Value {
|
|
|
134
141
|
_ if status.is_server_error() => "EXECUTION_FAILED",
|
|
135
142
|
_ => "REQUEST_FAILED",
|
|
136
143
|
};
|
|
137
|
-
let parsed =
|
|
138
|
-
|
|
139
|
-
let
|
|
140
|
-
|
|
144
|
+
let parsed =
|
|
145
|
+
serde_json::from_str::<Value>(&message).unwrap_or_else(|_| json!({ "message": message }));
|
|
146
|
+
let mut body = if parsed.is_object() {
|
|
147
|
+
parsed
|
|
148
|
+
} else {
|
|
149
|
+
json!({ "message": parsed })
|
|
150
|
+
};
|
|
151
|
+
let object = body
|
|
152
|
+
.as_object_mut()
|
|
153
|
+
.expect("normalized error body is an object");
|
|
154
|
+
let resolved_code = object
|
|
155
|
+
.get("code")
|
|
156
|
+
.and_then(Value::as_str)
|
|
157
|
+
.unwrap_or(code)
|
|
158
|
+
.to_string();
|
|
141
159
|
object.insert("error".into(), json!(resolved_code));
|
|
142
160
|
object.insert("code".into(), json!(resolved_code));
|
|
143
|
-
object.entry("message").or_insert_with(||
|
|
161
|
+
object.entry("message").or_insert_with(|| {
|
|
162
|
+
json!(if status == StatusCode::FORBIDDEN {
|
|
163
|
+
"request is not authorized"
|
|
164
|
+
} else {
|
|
165
|
+
status.canonical_reason().unwrap_or("request failed")
|
|
166
|
+
})
|
|
167
|
+
});
|
|
144
168
|
object.insert("request_id".into(), json!(request_id));
|
|
145
169
|
body
|
|
146
170
|
}
|
|
@@ -414,24 +438,112 @@ struct ApplicationDiscoveryRequest {
|
|
|
414
438
|
environment: String,
|
|
415
439
|
}
|
|
416
440
|
#[derive(Deserialize)]
|
|
417
|
-
struct ProposalScopeRequest {
|
|
441
|
+
struct ProposalScopeRequest {
|
|
442
|
+
application_id: String,
|
|
443
|
+
#[serde(default = "production_environment")]
|
|
444
|
+
environment: String,
|
|
445
|
+
#[serde(default)]
|
|
446
|
+
status: Option<String>,
|
|
447
|
+
#[serde(default)]
|
|
448
|
+
limit: Option<usize>,
|
|
449
|
+
#[serde(default)]
|
|
450
|
+
cursor: Option<String>,
|
|
451
|
+
#[serde(default)]
|
|
452
|
+
current_evidence_hash: Option<String>,
|
|
453
|
+
}
|
|
454
|
+
#[derive(Deserialize)]
|
|
455
|
+
struct CreateProposalRequest {
|
|
456
|
+
application_id: String,
|
|
457
|
+
#[serde(default = "production_environment")]
|
|
458
|
+
environment: String,
|
|
459
|
+
base_contract_hash: String,
|
|
460
|
+
base_flow_hash: String,
|
|
461
|
+
proposed_flow: String,
|
|
462
|
+
#[serde(default)]
|
|
463
|
+
proposal_version: Option<u64>,
|
|
464
|
+
#[serde(default)]
|
|
465
|
+
contract_diff: Option<Value>,
|
|
466
|
+
#[serde(default)]
|
|
467
|
+
source_plan: Option<Value>,
|
|
468
|
+
summary: String,
|
|
469
|
+
#[serde(default)]
|
|
470
|
+
warnings: Vec<String>,
|
|
471
|
+
#[serde(default)]
|
|
472
|
+
metadata: Value,
|
|
473
|
+
parent_proposal_id: Option<String>,
|
|
474
|
+
expires_at: Option<String>,
|
|
475
|
+
#[serde(default)]
|
|
476
|
+
status: Option<String>,
|
|
477
|
+
#[serde(default)]
|
|
478
|
+
understanding: Option<Value>,
|
|
479
|
+
#[serde(default)]
|
|
480
|
+
evidence_hash: Option<String>,
|
|
481
|
+
#[serde(default)]
|
|
482
|
+
understanding_hash: Option<String>,
|
|
483
|
+
}
|
|
484
|
+
#[derive(Deserialize, Serialize)]
|
|
485
|
+
struct ExecuteIntentRequest {
|
|
486
|
+
application_id: String,
|
|
487
|
+
#[serde(default = "production_environment")]
|
|
488
|
+
environment: String,
|
|
489
|
+
#[serde(alias = "raw_request")]
|
|
490
|
+
request: String,
|
|
491
|
+
}
|
|
492
|
+
#[derive(Deserialize)]
|
|
493
|
+
struct IntentHashRequest {
|
|
494
|
+
application_id: String,
|
|
495
|
+
environment: String,
|
|
496
|
+
intent_hash: String,
|
|
497
|
+
}
|
|
498
|
+
#[derive(Deserialize)]
|
|
499
|
+
struct ProposalActorRequest {
|
|
500
|
+
application_id: String,
|
|
501
|
+
#[serde(default = "production_environment")]
|
|
502
|
+
environment: String,
|
|
503
|
+
proposed_manifest: Option<ApplicationManifest>,
|
|
504
|
+
}
|
|
418
505
|
#[derive(Deserialize)]
|
|
419
|
-
struct
|
|
420
|
-
|
|
421
|
-
#[serde(default
|
|
422
|
-
|
|
506
|
+
struct ProposalStatusRequest {
|
|
507
|
+
application_id: String,
|
|
508
|
+
#[serde(default = "production_environment")]
|
|
509
|
+
environment: String,
|
|
510
|
+
status: String,
|
|
511
|
+
#[serde(default)]
|
|
512
|
+
approve_authorization: bool,
|
|
513
|
+
#[serde(default)]
|
|
514
|
+
reason: Option<String>,
|
|
515
|
+
}
|
|
423
516
|
#[derive(Deserialize)]
|
|
424
|
-
struct
|
|
425
|
-
|
|
517
|
+
struct ProposalLifecycleRequest {
|
|
518
|
+
application_id: String,
|
|
519
|
+
#[serde(default = "production_environment")]
|
|
520
|
+
environment: String,
|
|
521
|
+
#[serde(default)]
|
|
522
|
+
approve_authorization: bool,
|
|
523
|
+
#[serde(default)]
|
|
524
|
+
reason: Option<String>,
|
|
525
|
+
}
|
|
426
526
|
#[derive(Deserialize)]
|
|
427
|
-
struct
|
|
428
|
-
|
|
527
|
+
struct PreviewSimulationRequest {
|
|
528
|
+
event: String,
|
|
529
|
+
}
|
|
429
530
|
#[derive(Deserialize, Default)]
|
|
430
|
-
struct EventScopeRequest {
|
|
531
|
+
struct EventScopeRequest {
|
|
532
|
+
application_id: Option<String>,
|
|
533
|
+
}
|
|
431
534
|
#[derive(Deserialize)]
|
|
432
|
-
struct SignUpRequest {
|
|
535
|
+
struct SignUpRequest {
|
|
536
|
+
application_id: String,
|
|
537
|
+
email: String,
|
|
538
|
+
password: String,
|
|
539
|
+
display_name: Option<String>,
|
|
540
|
+
}
|
|
433
541
|
#[derive(Deserialize)]
|
|
434
|
-
struct SignInRequest {
|
|
542
|
+
struct SignInRequest {
|
|
543
|
+
application_id: Option<String>,
|
|
544
|
+
email: String,
|
|
545
|
+
password: String,
|
|
546
|
+
}
|
|
435
547
|
#[derive(Deserialize)]
|
|
436
548
|
struct StateContractTarget {
|
|
437
549
|
application_id: String,
|
|
@@ -2130,6 +2242,7 @@ async fn platform_overview(
|
|
|
2130
2242
|
let (users, tenants, applications, tenant_memberships, application_memberships) =
|
|
2131
2243
|
state.tenancy.platform_inventory().map_err(control_error)?;
|
|
2132
2244
|
let identities = state.identities.list().map_err(control_error)?;
|
|
2245
|
+
let runtime_metrics = state.metrics.snapshot();
|
|
2133
2246
|
Ok(Json(json!({
|
|
2134
2247
|
"owner": principal.identity_id,
|
|
2135
2248
|
"users": users,
|
|
@@ -2138,6 +2251,11 @@ async fn platform_overview(
|
|
|
2138
2251
|
"tenant_memberships": tenant_memberships,
|
|
2139
2252
|
"application_memberships": application_memberships,
|
|
2140
2253
|
"identities": identities,
|
|
2254
|
+
"adoption": {
|
|
2255
|
+
"measured_at": unix_seconds_i64().max(0) as u64,
|
|
2256
|
+
"runtime_process_started_seconds_ago": state.started_at.elapsed().as_secs(),
|
|
2257
|
+
"runtime": runtime_metrics,
|
|
2258
|
+
},
|
|
2141
2259
|
})))
|
|
2142
2260
|
}
|
|
2143
2261
|
async fn platform_update_user(
|
|
@@ -2279,7 +2397,10 @@ async fn delete_certification_fixture(
|
|
|
2279
2397
|
|| ring::constant_time::verify_slices_are_equal(expected.as_bytes(), provided.as_bytes())
|
|
2280
2398
|
.is_err()
|
|
2281
2399
|
{
|
|
2282
|
-
return Err(ApiError(
|
|
2400
|
+
return Err(ApiError(
|
|
2401
|
+
StatusCode::FORBIDDEN,
|
|
2402
|
+
"certification recovery denied".into(),
|
|
2403
|
+
));
|
|
2283
2404
|
}
|
|
2284
2405
|
let tenant_id = state
|
|
2285
2406
|
.tenancy
|
|
@@ -3701,6 +3822,9 @@ fn application_scope(
|
|
|
3701
3822
|
StatusCode::NOT_FOUND,
|
|
3702
3823
|
"application not found".into(),
|
|
3703
3824
|
))?;
|
|
3825
|
+
if std::env::var("FELTDB_INTENT_AUTHORITY_KEY_ID").as_deref() == Ok(actor) {
|
|
3826
|
+
return Ok(tenant_id);
|
|
3827
|
+
}
|
|
3704
3828
|
if !state
|
|
3705
3829
|
.tenancy
|
|
3706
3830
|
.authorize_application(actor, &tenant_id, application_id, capability)
|
|
@@ -3907,9 +4031,8 @@ async fn commit_application_draft(
|
|
|
3907
4031
|
.commit(&tenant, &application_id, &draft_id, &principal.key_id)
|
|
3908
4032
|
.map_err(manifest_error)?;
|
|
3909
4033
|
let encoded_revision = percent_encode_path_segment(&revision.revision_id);
|
|
3910
|
-
let mut response = serde_json::to_value(revision)
|
|
3911
|
-
ApiError(StatusCode::INTERNAL_SERVER_ERROR, error.to_string())
|
|
3912
|
-
})?;
|
|
4034
|
+
let mut response = serde_json::to_value(revision)
|
|
4035
|
+
.map_err(|error| ApiError(StatusCode::INTERNAL_SERVER_ERROR, error.to_string()))?;
|
|
3913
4036
|
response
|
|
3914
4037
|
.as_object_mut()
|
|
3915
4038
|
.expect("application revisions serialize as objects")
|
|
@@ -4358,13 +4481,13 @@ async fn import_application_draft(
|
|
|
4358
4481
|
|
|
4359
4482
|
fn runtime_inventory(
|
|
4360
4483
|
state: &AppState,
|
|
4361
|
-
|
|
4484
|
+
_actor: &str,
|
|
4362
4485
|
application_id: &str,
|
|
4363
4486
|
manifest: &ApplicationManifest,
|
|
4364
4487
|
) -> Result<RuntimeInventory, ApiError> {
|
|
4365
4488
|
let connections = state
|
|
4366
4489
|
.connections
|
|
4367
|
-
.
|
|
4490
|
+
.application_connections(application_id)
|
|
4368
4491
|
.map_err(control_error)?;
|
|
4369
4492
|
let connection_ids = connections
|
|
4370
4493
|
.into_iter()
|
|
@@ -4442,137 +4565,1335 @@ async fn get_active_application_runtime(
|
|
|
4442
4565
|
revision,
|
|
4443
4566
|
&input.environment,
|
|
4444
4567
|
)?;
|
|
4445
|
-
Ok(Json(
|
|
4446
|
-
json!({"contract":contract,"runtime":state.runtimes.active(&app.tenant_id,&application_id,revision,&input.environment)}),
|
|
4447
|
-
))
|
|
4568
|
+
Ok(Json(
|
|
4569
|
+
json!({"contract":contract,"runtime":state.runtimes.active(&app.tenant_id,&application_id,revision,&input.environment)}),
|
|
4570
|
+
))
|
|
4571
|
+
}
|
|
4572
|
+
async fn discover_application(
|
|
4573
|
+
State(state): State<AppState>,
|
|
4574
|
+
Extension(principal): Extension<Principal>,
|
|
4575
|
+
Query(input): Query<ApplicationDiscoveryRequest>,
|
|
4576
|
+
) -> Result<Json<Value>, ApiError> {
|
|
4577
|
+
let tenant = application_scope(
|
|
4578
|
+
&state,
|
|
4579
|
+
&principal.key_id,
|
|
4580
|
+
&input.application_id,
|
|
4581
|
+
"application:read",
|
|
4582
|
+
)?;
|
|
4583
|
+
let revision_id = state
|
|
4584
|
+
.applications
|
|
4585
|
+
.pointers(&input.application_id)
|
|
4586
|
+
.get(&input.environment)
|
|
4587
|
+
.cloned()
|
|
4588
|
+
.ok_or(ApiError(
|
|
4589
|
+
StatusCode::NOT_FOUND,
|
|
4590
|
+
"application environment has no active contract".into(),
|
|
4591
|
+
))?;
|
|
4592
|
+
let revision = state
|
|
4593
|
+
.applications
|
|
4594
|
+
.revision(&tenant, &input.application_id, &revision_id)
|
|
4595
|
+
.ok_or(ApiError(
|
|
4596
|
+
StatusCode::NOT_FOUND,
|
|
4597
|
+
"application revision not found".into(),
|
|
4598
|
+
))?;
|
|
4599
|
+
let contract_hash = revision
|
|
4600
|
+
.manifest
|
|
4601
|
+
.metadata
|
|
4602
|
+
.labels
|
|
4603
|
+
.get("feltdb.contract_hash")
|
|
4604
|
+
.unwrap_or(&revision.manifest_hash);
|
|
4605
|
+
let dsl_version = revision
|
|
4606
|
+
.manifest
|
|
4607
|
+
.metadata
|
|
4608
|
+
.labels
|
|
4609
|
+
.get("feltdb.dsl_version")
|
|
4610
|
+
.and_then(|value| value.parse::<u32>().ok())
|
|
4611
|
+
.unwrap_or(1);
|
|
4612
|
+
let application_name = state
|
|
4613
|
+
.tenancy
|
|
4614
|
+
.application_for(&principal.key_id, &input.application_id)
|
|
4615
|
+
.map(|application| application.name);
|
|
4616
|
+
let flow_hash = revision.manifest.metadata.labels.get("feltdb.flow_hash");
|
|
4617
|
+
Ok(Json(json!({
|
|
4618
|
+
"application_id": input.application_id,
|
|
4619
|
+
"application_name": application_name,
|
|
4620
|
+
"environment": input.environment,
|
|
4621
|
+
"version": revision.revision_number,
|
|
4622
|
+
"revision_id": revision.revision_id,
|
|
4623
|
+
"contract_hash": contract_hash,
|
|
4624
|
+
"flow_hash": flow_hash,
|
|
4625
|
+
"dsl_version": dsl_version,
|
|
4626
|
+
"snapshot": {
|
|
4627
|
+
"application": { "application_id": input.application_id, "environment": input.environment },
|
|
4628
|
+
"contract_version": revision.revision_number,
|
|
4629
|
+
"contract_hash": contract_hash,
|
|
4630
|
+
"dsl_version": dsl_version,
|
|
4631
|
+
"schema": revision.manifest.collections,
|
|
4632
|
+
"indexes": revision.manifest.indexes,
|
|
4633
|
+
"policies": revision.manifest.policies,
|
|
4634
|
+
"workflows": revision.manifest.workflows,
|
|
4635
|
+
"agents": revision.manifest.agents,
|
|
4636
|
+
"capabilities": revision.manifest.capabilities,
|
|
4637
|
+
"modules": revision.manifest.modules,
|
|
4638
|
+
"supported_primitives": ["text","integer","number","boolean","datetime","date","time","json","uuid","decimal","money","bigint","email","url","phone","binary","file","geo","object"]
|
|
4639
|
+
}
|
|
4640
|
+
})))
|
|
4641
|
+
}
|
|
4642
|
+
|
|
4643
|
+
const DEFAULT_INSPECTION_LIMIT: usize = 50;
|
|
4644
|
+
const MAX_INSPECTION_LIMIT: usize = 100;
|
|
4645
|
+
fn inspection_error(status: StatusCode, code: &str, message: impl Into<String>) -> ApiError {
|
|
4646
|
+
ApiError::structured(status, json!({"code":code,"message":message.into()}))
|
|
4647
|
+
}
|
|
4648
|
+
fn active_inspection_contract(
|
|
4649
|
+
state: &AppState,
|
|
4650
|
+
principal: &Principal,
|
|
4651
|
+
application_id: &str,
|
|
4652
|
+
environment: &str,
|
|
4653
|
+
) -> Result<ApplicationRuntimeContract, ApiError> {
|
|
4654
|
+
application_scope(state, &principal.key_id, application_id, "state:read")?;
|
|
4655
|
+
let revision = state
|
|
4656
|
+
.applications
|
|
4657
|
+
.pointers(application_id)
|
|
4658
|
+
.get(environment)
|
|
4659
|
+
.cloned()
|
|
4660
|
+
.ok_or_else(|| {
|
|
4661
|
+
inspection_error(
|
|
4662
|
+
StatusCode::NOT_FOUND,
|
|
4663
|
+
"collection_not_found",
|
|
4664
|
+
"application environment has no active contract",
|
|
4665
|
+
)
|
|
4666
|
+
})?;
|
|
4667
|
+
revision_contract(
|
|
4668
|
+
state,
|
|
4669
|
+
&principal.key_id,
|
|
4670
|
+
application_id,
|
|
4671
|
+
&revision,
|
|
4672
|
+
environment,
|
|
4673
|
+
)
|
|
4674
|
+
}
|
|
4675
|
+
fn inspectable_collection<'a>(
|
|
4676
|
+
contract: &'a ApplicationRuntimeContract,
|
|
4677
|
+
collection: &str,
|
|
4678
|
+
) -> Result<&'a feltdb::state_contract::CollectionSchema, ApiError> {
|
|
4679
|
+
if collection.starts_with("_feltdb") {
|
|
4680
|
+
return Err(inspection_error(
|
|
4681
|
+
StatusCode::FORBIDDEN,
|
|
4682
|
+
"system_collection",
|
|
4683
|
+
"system collections require their dedicated Service API",
|
|
4684
|
+
));
|
|
4685
|
+
}
|
|
4686
|
+
contract
|
|
4687
|
+
.state
|
|
4688
|
+
.schema
|
|
4689
|
+
.collections
|
|
4690
|
+
.iter()
|
|
4691
|
+
.find(|value| value.name == collection)
|
|
4692
|
+
.ok_or_else(|| {
|
|
4693
|
+
inspection_error(
|
|
4694
|
+
StatusCode::NOT_FOUND,
|
|
4695
|
+
"collection_not_found",
|
|
4696
|
+
format!("collection {collection} is not part of the active application contract"),
|
|
4697
|
+
)
|
|
4698
|
+
})
|
|
4699
|
+
}
|
|
4700
|
+
fn secret_name(name: &str) -> bool {
|
|
4701
|
+
let value = name.to_ascii_lowercase();
|
|
4702
|
+
[
|
|
4703
|
+
"secret",
|
|
4704
|
+
"password",
|
|
4705
|
+
"token",
|
|
4706
|
+
"api_key",
|
|
4707
|
+
"apikey",
|
|
4708
|
+
"credential",
|
|
4709
|
+
"private_key",
|
|
4710
|
+
]
|
|
4711
|
+
.iter()
|
|
4712
|
+
.any(|part| value.contains(part))
|
|
4713
|
+
}
|
|
4714
|
+
fn redact_inspection_value(value: &mut Value) {
|
|
4715
|
+
match value {
|
|
4716
|
+
Value::Object(object) => {
|
|
4717
|
+
for (key, child) in object.iter_mut() {
|
|
4718
|
+
if key.eq_ignore_ascii_case("secrets") && child.is_array() {
|
|
4719
|
+
redact_inspection_value(child)
|
|
4720
|
+
} else if secret_name(key) {
|
|
4721
|
+
*child = json!("[REDACTED]")
|
|
4722
|
+
} else {
|
|
4723
|
+
redact_inspection_value(child)
|
|
4724
|
+
}
|
|
4725
|
+
}
|
|
4726
|
+
}
|
|
4727
|
+
Value::Array(values) => {
|
|
4728
|
+
for child in values {
|
|
4729
|
+
redact_inspection_value(child)
|
|
4730
|
+
}
|
|
4731
|
+
}
|
|
4732
|
+
Value::String(text)
|
|
4733
|
+
if text.contains("sk_live_")
|
|
4734
|
+
|| text.contains("sk_test_")
|
|
4735
|
+
|| text.contains("whsec_")
|
|
4736
|
+
|| text.contains("-----BEGIN PRIVATE KEY-----") =>
|
|
4737
|
+
{
|
|
4738
|
+
*value = json!("[REDACTED]")
|
|
4739
|
+
}
|
|
4740
|
+
_ => {}
|
|
4741
|
+
}
|
|
4742
|
+
}
|
|
4743
|
+
fn relationship_metadata(
|
|
4744
|
+
contract: &ApplicationRuntimeContract,
|
|
4745
|
+
collection: &str,
|
|
4746
|
+
record: &Value,
|
|
4747
|
+
) -> Vec<Value> {
|
|
4748
|
+
let Some(definition) = contract
|
|
4749
|
+
.state
|
|
4750
|
+
.collections
|
|
4751
|
+
.iter()
|
|
4752
|
+
.find(|value| value.name == collection)
|
|
4753
|
+
else {
|
|
4754
|
+
return vec![];
|
|
4755
|
+
};
|
|
4756
|
+
definition.fields.iter().filter_map(|field|field.reference.as_ref().and_then(|target|record.get(&field.name).and_then(Value::as_str).map(|id|json!({"name":field.name,"source_collection":collection,"target_collection":target,"cardinality":"one","foreign_field":field.name,"reference":{"collection":target,"id":id}})))).collect()
|
|
4757
|
+
}
|
|
4758
|
+
fn inspection_cursor(
|
|
4759
|
+
collection: &str,
|
|
4760
|
+
state_version: u64,
|
|
4761
|
+
after: &str,
|
|
4762
|
+
filter_hash: &str,
|
|
4763
|
+
) -> String {
|
|
4764
|
+
base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(serde_json::to_vec(&json!({"v":1,"state":state_version,"after":after,"query":format!("{:x}",Sha256::digest(format!("{collection}:{filter_hash}").as_bytes()))})).unwrap_or_default())
|
|
4765
|
+
}
|
|
4766
|
+
fn parse_inspection_cursor(
|
|
4767
|
+
cursor: &str,
|
|
4768
|
+
collection: &str,
|
|
4769
|
+
current_version: u64,
|
|
4770
|
+
filter_hash: &str,
|
|
4771
|
+
) -> Result<String, ApiError> {
|
|
4772
|
+
let value = base64::engine::general_purpose::URL_SAFE_NO_PAD
|
|
4773
|
+
.decode(cursor)
|
|
4774
|
+
.ok()
|
|
4775
|
+
.and_then(|bytes| serde_json::from_slice::<Value>(&bytes).ok())
|
|
4776
|
+
.ok_or_else(|| {
|
|
4777
|
+
inspection_error(
|
|
4778
|
+
StatusCode::UNPROCESSABLE_ENTITY,
|
|
4779
|
+
"invalid_cursor",
|
|
4780
|
+
"cursor is malformed",
|
|
4781
|
+
)
|
|
4782
|
+
})?;
|
|
4783
|
+
let expected = format!(
|
|
4784
|
+
"{:x}",
|
|
4785
|
+
Sha256::digest(format!("{collection}:{filter_hash}").as_bytes())
|
|
4786
|
+
);
|
|
4787
|
+
if value.get("v").and_then(Value::as_u64) != Some(1)
|
|
4788
|
+
|| value.get("state").and_then(Value::as_u64) != Some(current_version)
|
|
4789
|
+
|| value.get("query").and_then(Value::as_str) != Some(&expected)
|
|
4790
|
+
{
|
|
4791
|
+
return Err(inspection_error(
|
|
4792
|
+
StatusCode::UNPROCESSABLE_ENTITY,
|
|
4793
|
+
"invalid_cursor",
|
|
4794
|
+
"cursor is stale or belongs to another query",
|
|
4795
|
+
));
|
|
4796
|
+
}
|
|
4797
|
+
value
|
|
4798
|
+
.get("after")
|
|
4799
|
+
.and_then(Value::as_str)
|
|
4800
|
+
.map(str::to_string)
|
|
4801
|
+
.ok_or_else(|| {
|
|
4802
|
+
inspection_error(
|
|
4803
|
+
StatusCode::UNPROCESSABLE_ENTITY,
|
|
4804
|
+
"invalid_cursor",
|
|
4805
|
+
"cursor position is invalid",
|
|
4806
|
+
)
|
|
4807
|
+
})
|
|
4808
|
+
}
|
|
4809
|
+
async fn inspect_application_schema(
|
|
4810
|
+
State(state): State<AppState>,
|
|
4811
|
+
Extension(principal): Extension<Principal>,
|
|
4812
|
+
Query(input): Query<ApplicationDiscoveryRequest>,
|
|
4813
|
+
) -> Result<Json<Value>, ApiError> {
|
|
4814
|
+
let contract = active_inspection_contract(
|
|
4815
|
+
&state,
|
|
4816
|
+
&principal,
|
|
4817
|
+
&input.application_id,
|
|
4818
|
+
&input.environment,
|
|
4819
|
+
)?;
|
|
4820
|
+
let collections=contract.state.collections.iter().map(|collection|{let policy=matching_state_policy(&contract,&collection.name);
|
|
4821
|
+
let relationships=collection.fields.iter().filter_map(|field|field.reference.as_ref().map(|target|json!({"name":field.name,"source_collection":collection.name,"target_collection":target,"cardinality":"one","foreign_field":field.name}))).collect::<Vec<_>>();
|
|
4822
|
+
let reverse=contract.state.collections.iter().flat_map(|source|source.fields.iter().filter_map(|field|field.reference.as_ref().filter(|target|*target==&collection.name).map(|_|json!({"name":format!("{}_{}",source.name,field.name),"source_collection":source.name,"target_collection":collection.name,"cardinality":"many","foreign_field":field.name})))).collect::<Vec<_>>();
|
|
4823
|
+
json!({"collection":collection.name,"description":"","fields":collection.fields,"relationships":relationships.into_iter().chain(reverse).collect::<Vec<_>>(),"indexes":contract.state.indexes.iter().filter(|index|index.collection==collection.name).collect::<Vec<_>>(),"authorization":policy.map(|value|json!({"read":value.read,"write":value.write,"capabilities":value.capabilities}))})}).collect::<Vec<_>>();
|
|
4824
|
+
Ok(Json(
|
|
4825
|
+
json!({"application":{"application_id":input.application_id,"environment":input.environment},"contract_version":contract.revision_number,"collections":collections}),
|
|
4826
|
+
))
|
|
4827
|
+
}
|
|
4828
|
+
async fn inspect_collection(
|
|
4829
|
+
State(state): State<AppState>,
|
|
4830
|
+
Extension(principal): Extension<Principal>,
|
|
4831
|
+
Path(collection): Path<String>,
|
|
4832
|
+
Query(params): Query<HashMap<String, String>>,
|
|
4833
|
+
) -> Result<Json<Value>, ApiError> {
|
|
4834
|
+
let application_id = params.get("application_id").ok_or_else(|| {
|
|
4835
|
+
inspection_error(
|
|
4836
|
+
StatusCode::BAD_REQUEST,
|
|
4837
|
+
"inspection_not_allowed",
|
|
4838
|
+
"application_id is required",
|
|
4839
|
+
)
|
|
4840
|
+
})?;
|
|
4841
|
+
let environment = params
|
|
4842
|
+
.get("environment")
|
|
4843
|
+
.map(String::as_str)
|
|
4844
|
+
.unwrap_or("production");
|
|
4845
|
+
let contract = active_inspection_contract(&state, &principal, application_id, environment)?;
|
|
4846
|
+
let definition = inspectable_collection(&contract, &collection)?;
|
|
4847
|
+
let limit = params
|
|
4848
|
+
.get("limit")
|
|
4849
|
+
.map(|value| value.parse::<usize>())
|
|
4850
|
+
.transpose()
|
|
4851
|
+
.map_err(|_| {
|
|
4852
|
+
inspection_error(
|
|
4853
|
+
StatusCode::UNPROCESSABLE_ENTITY,
|
|
4854
|
+
"invalid_pagination",
|
|
4855
|
+
"limit must be an integer",
|
|
4856
|
+
)
|
|
4857
|
+
})?
|
|
4858
|
+
.unwrap_or(DEFAULT_INSPECTION_LIMIT);
|
|
4859
|
+
if limit == 0 || limit > MAX_INSPECTION_LIMIT {
|
|
4860
|
+
return Err(inspection_error(
|
|
4861
|
+
StatusCode::UNPROCESSABLE_ENTITY,
|
|
4862
|
+
"invalid_pagination",
|
|
4863
|
+
format!("limit must be between 1 and {MAX_INSPECTION_LIMIT}"),
|
|
4864
|
+
));
|
|
4865
|
+
}
|
|
4866
|
+
let reserved = ["application_id", "environment", "limit", "cursor"];
|
|
4867
|
+
let mut filters = Vec::new();
|
|
4868
|
+
for (field, value) in params
|
|
4869
|
+
.iter()
|
|
4870
|
+
.filter(|(key, _)| !reserved.contains(&key.as_str()))
|
|
4871
|
+
{
|
|
4872
|
+
if field.contains('[') || field.contains(']') {
|
|
4873
|
+
return Err(inspection_error(
|
|
4874
|
+
StatusCode::UNPROCESSABLE_ENTITY,
|
|
4875
|
+
"unsupported_filter",
|
|
4876
|
+
format!("unsupported filter operator in {field}"),
|
|
4877
|
+
));
|
|
4878
|
+
}
|
|
4879
|
+
if !definition
|
|
4880
|
+
.fields
|
|
4881
|
+
.iter()
|
|
4882
|
+
.any(|candidate| candidate.name == *field)
|
|
4883
|
+
{
|
|
4884
|
+
return Err(inspection_error(
|
|
4885
|
+
StatusCode::UNPROCESSABLE_ENTITY,
|
|
4886
|
+
"field_not_found",
|
|
4887
|
+
format!("field {field} is not in collection {collection}"),
|
|
4888
|
+
));
|
|
4889
|
+
}
|
|
4890
|
+
filters.push(QueryFilter::Eq {
|
|
4891
|
+
field: field.clone(),
|
|
4892
|
+
value: Value::String(value.clone()),
|
|
4893
|
+
});
|
|
4894
|
+
}
|
|
4895
|
+
filters.sort_by(|left, right| format!("{left:?}").cmp(&format!("{right:?}")));
|
|
4896
|
+
let filter_hash = format!(
|
|
4897
|
+
"{:x}",
|
|
4898
|
+
Sha256::digest(serde_json::to_vec(&filters).unwrap_or_default())
|
|
4899
|
+
);
|
|
4900
|
+
let state_version = state.db.sequence()?;
|
|
4901
|
+
let mut after = params
|
|
4902
|
+
.get("cursor")
|
|
4903
|
+
.map(|value| parse_inspection_cursor(value, &collection, state_version, &filter_hash))
|
|
4904
|
+
.transpose()?;
|
|
4905
|
+
let policy = matching_state_policy(&contract, &collection).ok_or_else(|| {
|
|
4906
|
+
inspection_error(
|
|
4907
|
+
StatusCode::FORBIDDEN,
|
|
4908
|
+
"inspection_not_allowed",
|
|
4909
|
+
"collection has no inspection policy",
|
|
4910
|
+
)
|
|
4911
|
+
})?;
|
|
4912
|
+
let read_policy = policy.read.as_deref().and_then(PolicySubject::from_str);
|
|
4913
|
+
if read_policy.is_none()
|
|
4914
|
+
&& !policy
|
|
4915
|
+
.capabilities
|
|
4916
|
+
.iter()
|
|
4917
|
+
.any(|value| value == "state:read")
|
|
4918
|
+
{
|
|
4919
|
+
return Err(inspection_error(
|
|
4920
|
+
StatusCode::FORBIDDEN,
|
|
4921
|
+
"forbidden",
|
|
4922
|
+
"application policy denies collection inspection",
|
|
4923
|
+
));
|
|
4924
|
+
}
|
|
4925
|
+
let auth_state = if matches!(read_policy, Some(PolicySubject::Member)) {
|
|
4926
|
+
Some(AuthorizationState::new(
|
|
4927
|
+
std::sync::Arc::new(contract.state.schema.clone()),
|
|
4928
|
+
contract.environment.state_namespace.clone(),
|
|
4929
|
+
std::sync::Arc::new(state.db.state_rows()?),
|
|
4930
|
+
))
|
|
4931
|
+
} else {
|
|
4932
|
+
None
|
|
4933
|
+
};
|
|
4934
|
+
let capability = format!("{}:{}", contract.environment.state_namespace, collection);
|
|
4935
|
+
let mut accepted: Vec<(String, Value)> = Vec::new();
|
|
4936
|
+
let mut exhausted = false;
|
|
4937
|
+
while accepted.len() <= limit && !exhausted {
|
|
4938
|
+
let page = state
|
|
4939
|
+
.db
|
|
4940
|
+
.list_collection_page(&capability, after.as_deref(), 256)?;
|
|
4941
|
+
if page.len() < 256 {
|
|
4942
|
+
exhausted = true;
|
|
4943
|
+
}
|
|
4944
|
+
if page.is_empty() {
|
|
4945
|
+
break;
|
|
4946
|
+
}
|
|
4947
|
+
for row in page {
|
|
4948
|
+
after = Some(row.key.clone());
|
|
4949
|
+
if !filters.iter().all(|filter| match filter {
|
|
4950
|
+
QueryFilter::Eq { field, value } => row.value.get(field) == Some(value),
|
|
4951
|
+
_ => false,
|
|
4952
|
+
}) {
|
|
4953
|
+
continue;
|
|
4954
|
+
}
|
|
4955
|
+
if let Some(subject) = read_policy.clone() {
|
|
4956
|
+
let mut context = RecordAuthorizationContext::new(
|
|
4957
|
+
Some(Actor::new(&principal.key_id)),
|
|
4958
|
+
collection.clone(),
|
|
4959
|
+
row.key.clone(),
|
|
4960
|
+
row.value.clone(),
|
|
4961
|
+
);
|
|
4962
|
+
if let Some(snapshot) = auth_state.clone() {
|
|
4963
|
+
context = context.with_state(snapshot);
|
|
4964
|
+
}
|
|
4965
|
+
if PolicyEvaluator::evaluate_record(subject, &context).is_err() {
|
|
4966
|
+
continue;
|
|
4967
|
+
}
|
|
4968
|
+
}
|
|
4969
|
+
accepted.push((row.key, row.value));
|
|
4970
|
+
if accepted.len() > limit {
|
|
4971
|
+
break;
|
|
4972
|
+
}
|
|
4973
|
+
}
|
|
4974
|
+
}
|
|
4975
|
+
let has_more = accepted.len() > limit;
|
|
4976
|
+
let last_key = accepted
|
|
4977
|
+
.get(limit.saturating_sub(1))
|
|
4978
|
+
.map(|value| value.0.clone());
|
|
4979
|
+
let records = accepted
|
|
4980
|
+
.into_iter()
|
|
4981
|
+
.take(limit)
|
|
4982
|
+
.map(|(id, mut record)| {
|
|
4983
|
+
let relationships = relationship_metadata(&contract, &collection, &record);
|
|
4984
|
+
redact_inspection_value(&mut record);
|
|
4985
|
+
if let Some(object) = record.as_object_mut() {
|
|
4986
|
+
object.entry("id").or_insert(json!(id));
|
|
4987
|
+
object.insert("_relationships".into(), json!(relationships));
|
|
4988
|
+
}
|
|
4989
|
+
record
|
|
4990
|
+
})
|
|
4991
|
+
.collect::<Vec<_>>();
|
|
4992
|
+
Ok(Json(
|
|
4993
|
+
json!({"collection":collection,"records":records,"pagination":{"limit":limit,"nextCursor":if has_more{last_key.map(|key|inspection_cursor(&collection,state_version,&key,&filter_hash))}else{None}},"state_version":state_version}),
|
|
4994
|
+
))
|
|
4995
|
+
}
|
|
4996
|
+
async fn inspect_record(
|
|
4997
|
+
State(state): State<AppState>,
|
|
4998
|
+
Extension(principal): Extension<Principal>,
|
|
4999
|
+
Path((collection, id)): Path<(String, String)>,
|
|
5000
|
+
Query(params): Query<HashMap<String, String>>,
|
|
5001
|
+
) -> Result<Json<Value>, ApiError> {
|
|
5002
|
+
let application_id = params.get("application_id").ok_or_else(|| {
|
|
5003
|
+
inspection_error(
|
|
5004
|
+
StatusCode::BAD_REQUEST,
|
|
5005
|
+
"inspection_not_allowed",
|
|
5006
|
+
"application_id is required",
|
|
5007
|
+
)
|
|
5008
|
+
})?;
|
|
5009
|
+
let environment = params
|
|
5010
|
+
.get("environment")
|
|
5011
|
+
.map(String::as_str)
|
|
5012
|
+
.unwrap_or("production");
|
|
5013
|
+
let contract = active_inspection_contract(&state, &principal, application_id, environment)?;
|
|
5014
|
+
inspectable_collection(&contract, &collection)?;
|
|
5015
|
+
let policy = matching_state_policy(&contract, &collection).ok_or_else(|| {
|
|
5016
|
+
inspection_error(
|
|
5017
|
+
StatusCode::FORBIDDEN,
|
|
5018
|
+
"inspection_not_allowed",
|
|
5019
|
+
"collection has no inspection policy",
|
|
5020
|
+
)
|
|
5021
|
+
})?;
|
|
5022
|
+
let read_policy = policy.read.as_deref().and_then(PolicySubject::from_str);
|
|
5023
|
+
if read_policy.is_none()
|
|
5024
|
+
&& !policy
|
|
5025
|
+
.capabilities
|
|
5026
|
+
.iter()
|
|
5027
|
+
.any(|value| value == "state:read")
|
|
5028
|
+
{
|
|
5029
|
+
return Err(inspection_error(
|
|
5030
|
+
StatusCode::FORBIDDEN,
|
|
5031
|
+
"forbidden",
|
|
5032
|
+
"application policy denies record inspection",
|
|
5033
|
+
));
|
|
5034
|
+
}
|
|
5035
|
+
let capability = format!("{}:{}", contract.environment.state_namespace, collection);
|
|
5036
|
+
let row = state
|
|
5037
|
+
.db
|
|
5038
|
+
.get_collection_record(&capability, &id)?
|
|
5039
|
+
.ok_or_else(|| {
|
|
5040
|
+
inspection_error(
|
|
5041
|
+
StatusCode::NOT_FOUND,
|
|
5042
|
+
"record_not_found",
|
|
5043
|
+
"record not found or is not authorized",
|
|
5044
|
+
)
|
|
5045
|
+
})?;
|
|
5046
|
+
if let Some(subject) = read_policy {
|
|
5047
|
+
let mut context = RecordAuthorizationContext::new(
|
|
5048
|
+
Some(Actor::new(&principal.key_id)),
|
|
5049
|
+
collection.clone(),
|
|
5050
|
+
id.clone(),
|
|
5051
|
+
row.value.clone(),
|
|
5052
|
+
);
|
|
5053
|
+
if matches!(subject, PolicySubject::Member) {
|
|
5054
|
+
context = context.with_state(AuthorizationState::new(
|
|
5055
|
+
std::sync::Arc::new(contract.state.schema.clone()),
|
|
5056
|
+
contract.environment.state_namespace.clone(),
|
|
5057
|
+
std::sync::Arc::new(state.db.state_rows()?),
|
|
5058
|
+
));
|
|
5059
|
+
}
|
|
5060
|
+
if PolicyEvaluator::evaluate_record(subject, &context).is_err() {
|
|
5061
|
+
return Err(inspection_error(
|
|
5062
|
+
StatusCode::NOT_FOUND,
|
|
5063
|
+
"record_not_found",
|
|
5064
|
+
"record not found or is not authorized",
|
|
5065
|
+
));
|
|
5066
|
+
}
|
|
5067
|
+
}
|
|
5068
|
+
let mut record = row.value;
|
|
5069
|
+
let relationships = relationship_metadata(&contract, &collection, &record);
|
|
5070
|
+
redact_inspection_value(&mut record);
|
|
5071
|
+
if let Some(object) = record.as_object_mut() {
|
|
5072
|
+
object.insert("id".into(), json!(id));
|
|
5073
|
+
}
|
|
5074
|
+
Ok(Json(
|
|
5075
|
+
json!({"collection":collection,"record":record,"relationships":relationships}),
|
|
5076
|
+
))
|
|
5077
|
+
}
|
|
5078
|
+
const PROPOSAL_COLLECTION: &str = "_feltdb_proposals";
|
|
5079
|
+
const PROPOSAL_EVENT_COLLECTION: &str = "_feltdb_proposal_events";
|
|
5080
|
+
const PROPOSAL_PREVIEW_COLLECTION: &str = "_feltdb_proposal_previews";
|
|
5081
|
+
const CONVERGENCE_COLLECTION: &str = "_feltdb_convergence";
|
|
5082
|
+
const INTENT_COLLECTION: &str = "_feltdb_intents";
|
|
5083
|
+
const INTENT_EVENT_COLLECTION: &str = "_feltdb_intent_events";
|
|
5084
|
+
|
|
5085
|
+
fn require_intent_service(headers: &HeaderMap) -> Result<(), ApiError> {
|
|
5086
|
+
let expected = std::env::var("FELTDB_INTENT_SERVICE_SECRET").map_err(|_| inspection_error(StatusCode::SERVICE_UNAVAILABLE, "intent_service_unconfigured", "Intent Engine service authentication is not configured"))?;
|
|
5087
|
+
let supplied = headers.get("x-feltdb-intent-service").and_then(|value| value.to_str().ok()).unwrap_or_default();
|
|
5088
|
+
if Sha256::digest(supplied.as_bytes()) != Sha256::digest(expected.as_bytes()) {
|
|
5089
|
+
return Err(inspection_error(StatusCode::FORBIDDEN, "forbidden", "invalid Intent Engine service credential"));
|
|
5090
|
+
}
|
|
5091
|
+
Ok(())
|
|
5092
|
+
}
|
|
5093
|
+
|
|
5094
|
+
async fn execute_intent(
|
|
5095
|
+
State(state): State<AppState>, Extension(principal): Extension<Principal>, headers: HeaderMap,
|
|
5096
|
+
Json(input): Json<ExecuteIntentRequest>,
|
|
5097
|
+
) -> Result<Json<Value>, ApiError> {
|
|
5098
|
+
application_scope(&state, &principal.key_id, &input.application_id, "application:write")?;
|
|
5099
|
+
if input.request.trim().is_empty() { return Err(inspection_error(StatusCode::BAD_REQUEST, "invalid_intent", "request is required")); }
|
|
5100
|
+
let endpoint = std::env::var("FELTDB_INTENT_ENGINE_URL").map_err(|_| inspection_error(StatusCode::SERVICE_UNAVAILABLE, "intent_engine_unavailable", "managed Intent Engine is not configured"))?;
|
|
5101
|
+
let secret = std::env::var("FELTDB_INTENT_SERVICE_SECRET").map_err(|_| inspection_error(StatusCode::SERVICE_UNAVAILABLE, "intent_engine_unavailable", "managed Intent Engine authentication is not configured"))?;
|
|
5102
|
+
let mut request = state.peer_client.post(format!("{}/internal/v1/intents", endpoint.trim_end_matches('/'))).header("x-feltdb-intent-service", secret).json(&input);
|
|
5103
|
+
if let Some(value) = headers.get("idempotency-key") { request = request.header("idempotency-key", value); }
|
|
5104
|
+
let response = request.send().await.map_err(|error| inspection_error(StatusCode::BAD_GATEWAY, "intent_engine_unavailable", error.to_string()))?;
|
|
5105
|
+
let status = response.status(); let value: Value = response.json().await.map_err(|error| inspection_error(StatusCode::BAD_GATEWAY, "invalid_intent_engine_response", error.to_string()))?;
|
|
5106
|
+
if !status.is_success() { let republish=value.get("error").and_then(Value::as_str)==Some("APPLICATION_REPUBLISH_REQUIRED");return Err(inspection_error(if republish {StatusCode::CONFLICT}else{StatusCode::BAD_GATEWAY},if republish{"application_republish_required"}else{"intent_execution_failed"},value.get("message").and_then(Value::as_str).unwrap_or("Intent Engine failed"))); }
|
|
5107
|
+
Ok(Json(value))
|
|
5108
|
+
}
|
|
5109
|
+
|
|
5110
|
+
async fn list_intents(State(state): State<AppState>, Extension(principal): Extension<Principal>, Query(input): Query<ProposalScopeRequest>) -> Result<Json<Value>, ApiError> {
|
|
5111
|
+
application_scope(&state,&principal.key_id,&input.application_id,"application:read")?;
|
|
5112
|
+
let mut intents=state.db.list_collection(INTENT_COLLECTION)?.into_iter().map(|row|row.value).filter(|value|proposal_scope_matches(value,&input.application_id,&input.environment)).collect::<Vec<_>>();
|
|
5113
|
+
intents.sort_by(|a,b|a.get("created_at").and_then(Value::as_str).cmp(&b.get("created_at").and_then(Value::as_str)));for value in &mut intents{if let Some(object)=value.as_object_mut(){object.remove("execution_result");}redact_inspection_value(value);}Ok(Json(json!({"intents":intents})))
|
|
5114
|
+
}
|
|
5115
|
+
async fn get_intent(State(state): State<AppState>, Extension(principal): Extension<Principal>, Path((id,operation)): Path<(String,String)>, Query(input): Query<ProposalScopeRequest>) -> Result<Json<Value>,ApiError>{
|
|
5116
|
+
application_scope(&state,&principal.key_id,&input.application_id,"application:read")?;let intent=required_value(&state,INTENT_COLLECTION,&id,"intent not found")?;if !proposal_scope_matches(&intent,&input.application_id,&input.environment){return Err(ApiError(StatusCode::NOT_FOUND,"intent not found".into()));}
|
|
5117
|
+
let value=match operation.as_str(){"understanding"=>intent.get("understanding").cloned().unwrap_or(Value::Null),"plan"=>intent.get("plan").cloned().unwrap_or(Value::Null),"proposal"=>intent.get("proposal_id").and_then(Value::as_str).map(|id|proposal_value(&state,id)).transpose()?.unwrap_or(Value::Null),"history"=>{let mut events=state.db.list_collection(INTENT_EVENT_COLLECTION)?.into_iter().map(|row|row.value).filter(|value|value.get("intent_id").and_then(Value::as_str)==Some(id.as_str())).collect::<Vec<_>>();events.sort_by(|a,b|a.get("created_at").and_then(Value::as_str).cmp(&b.get("created_at").and_then(Value::as_str)));json!({"events":events})},_=>return Err(ApiError(StatusCode::NOT_FOUND,"intent resource not found".into()))};Ok(Json(value))
|
|
5118
|
+
}
|
|
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
|
+
|
|
5125
|
+
fn proposal_contract_snapshot(
|
|
5126
|
+
revision: &ApplicationRevision,
|
|
5127
|
+
environment: &str,
|
|
5128
|
+
) -> Value {
|
|
5129
|
+
let contract_hash = revision.manifest.metadata.labels.get("feltdb.contract_hash").unwrap_or(&revision.manifest_hash);
|
|
5130
|
+
let flow_hash = revision.manifest.metadata.labels.get("feltdb.flow_hash").cloned();
|
|
5131
|
+
let dsl_version = revision.manifest.metadata.labels.get("feltdb.dsl_version").and_then(|value| value.parse::<u32>().ok()).unwrap_or(1);
|
|
5132
|
+
json!({
|
|
5133
|
+
"application":{"application_id":revision.application_id,"environment":environment},
|
|
5134
|
+
"contract_version":revision.revision_number,"contract_hash":contract_hash,"flow_hash":flow_hash,
|
|
5135
|
+
"dsl_version":dsl_version,"schema":revision.manifest.collections,"indexes":revision.manifest.indexes,
|
|
5136
|
+
"policies":revision.manifest.policies,"workflows":revision.manifest.workflows,"agents":revision.manifest.agents,
|
|
5137
|
+
"capabilities":revision.manifest.capabilities,"modules":revision.manifest.modules,
|
|
5138
|
+
"supported_primitives":["text","integer","number","boolean","datetime","date","time","json","uuid","decimal","money","bigint","email","url","phone","binary","file","geo","object"]
|
|
5139
|
+
})
|
|
5140
|
+
}
|
|
5141
|
+
|
|
5142
|
+
fn proposal_hash(value: &Value) -> String {
|
|
5143
|
+
format!("sha256:{:x}", Sha256::digest(serde_json::to_vec(value).unwrap_or_default()))
|
|
5144
|
+
}
|
|
5145
|
+
|
|
5146
|
+
fn preview_ttl_seconds() -> i64 {
|
|
5147
|
+
std::env::var("FELTDB_PROPOSAL_PREVIEW_TTL_SECONDS").ok().and_then(|value| value.parse().ok()).map(|value: i64| value.clamp(1, 86_400)).unwrap_or(3_600)
|
|
5148
|
+
}
|
|
5149
|
+
|
|
5150
|
+
fn preview_artifact(state: &AppState, id: &str) -> Result<Value, ApiError> {
|
|
5151
|
+
required_value(state, PROPOSAL_PREVIEW_COLLECTION, id, "preview not found")
|
|
5152
|
+
}
|
|
5153
|
+
|
|
5154
|
+
fn preview_available(value: &Value) -> bool {
|
|
5155
|
+
value.get("status").and_then(Value::as_str) == Some("ready")
|
|
5156
|
+
&& value.get("expires_at").and_then(Value::as_i64).is_some_and(|expires| expires > unix_seconds_i64())
|
|
5157
|
+
}
|
|
5158
|
+
|
|
5159
|
+
fn proposal_event(
|
|
5160
|
+
state: &AppState,
|
|
5161
|
+
proposal_id: &str,
|
|
5162
|
+
from: Option<&str>,
|
|
5163
|
+
to: &str,
|
|
5164
|
+
actor: &str,
|
|
5165
|
+
) -> Result<(), ApiError> {
|
|
5166
|
+
let id = format!("pe_{}", uuid::Uuid::new_v4().simple());
|
|
5167
|
+
let proposal = proposal_value(state, proposal_id).unwrap_or(Value::Null);
|
|
5168
|
+
let event_sequence = state.db.sequence()?.saturating_add(1);
|
|
5169
|
+
insert_canonical(
|
|
5170
|
+
state,
|
|
5171
|
+
PROPOSAL_EVENT_COLLECTION,
|
|
5172
|
+
&id,
|
|
5173
|
+
json!({ "id":id, "event_id":id, "proposal_id":proposal_id, "event_type":to,
|
|
5174
|
+
"from_status":from, "to_status":to, "actor":actor, "created_at":unix_seconds_i64(),
|
|
5175
|
+
"timestamp":unix_seconds_i64(), "event_sequence":event_sequence, "contract_hash":proposal.get("base_contract_hash"),
|
|
5176
|
+
"flow_hash":proposal.get("base_flow_hash"), "metadata":{} }),
|
|
5177
|
+
)?;
|
|
5178
|
+
Ok(())
|
|
5179
|
+
}
|
|
5180
|
+
fn proposal_value(state: &AppState, id: &str) -> Result<Value, ApiError> {
|
|
5181
|
+
required_value(state, PROPOSAL_COLLECTION, id, "proposal not found")
|
|
5182
|
+
}
|
|
5183
|
+
fn proposal_scope_matches(value: &Value, application_id: &str, environment: &str) -> bool {
|
|
5184
|
+
value.get("application_id").and_then(Value::as_str) == Some(application_id)
|
|
5185
|
+
&& value.get("environment").and_then(Value::as_str) == Some(environment)
|
|
5186
|
+
}
|
|
5187
|
+
async fn create_proposal(
|
|
5188
|
+
State(state): State<AppState>,
|
|
5189
|
+
Extension(principal): Extension<Principal>,
|
|
5190
|
+
Json(input): Json<CreateProposalRequest>,
|
|
5191
|
+
) -> Result<(StatusCode, Json<Value>), ApiError> {
|
|
5192
|
+
if input.status.is_some() {
|
|
5193
|
+
return Err(inspection_error(StatusCode::BAD_REQUEST, "invalid_proposal", "proposal lifecycle state is server-controlled"));
|
|
5194
|
+
}
|
|
5195
|
+
if input.proposal_version.unwrap_or(1) != 1 {
|
|
5196
|
+
return Err(inspection_error(StatusCode::UNPROCESSABLE_ENTITY, "invalid_proposal", "proposal_version must be 1"));
|
|
5197
|
+
}
|
|
5198
|
+
if input.contract_diff.as_ref().is_none_or(|value| !value.is_array())
|
|
5199
|
+
|| input.source_plan.as_ref().is_none_or(|value| !value.get("files").is_some_and(Value::is_array))
|
|
5200
|
+
{
|
|
5201
|
+
return Err(inspection_error(StatusCode::UNPROCESSABLE_ENTITY, "invalid_proposal", "contract_diff and source_plan.files are required arrays"));
|
|
5202
|
+
}
|
|
5203
|
+
let tenant = application_scope(
|
|
5204
|
+
&state,
|
|
5205
|
+
&principal.key_id,
|
|
5206
|
+
&input.application_id,
|
|
5207
|
+
"application:write",
|
|
5208
|
+
)?;
|
|
5209
|
+
let revision_id = state
|
|
5210
|
+
.applications
|
|
5211
|
+
.pointers(&input.application_id)
|
|
5212
|
+
.get(&input.environment)
|
|
5213
|
+
.cloned()
|
|
5214
|
+
.ok_or(ApiError(
|
|
5215
|
+
StatusCode::NOT_FOUND,
|
|
5216
|
+
"application environment has no active contract".into(),
|
|
5217
|
+
))?;
|
|
5218
|
+
let revision = state
|
|
5219
|
+
.applications
|
|
5220
|
+
.revision(&tenant, &input.application_id, &revision_id)
|
|
5221
|
+
.ok_or(ApiError(
|
|
5222
|
+
StatusCode::NOT_FOUND,
|
|
5223
|
+
"application revision not found".into(),
|
|
5224
|
+
))?;
|
|
5225
|
+
let contract_hash = revision
|
|
5226
|
+
.manifest
|
|
5227
|
+
.metadata
|
|
5228
|
+
.labels
|
|
5229
|
+
.get("feltdb.contract_hash")
|
|
5230
|
+
.unwrap_or(&revision.manifest_hash);
|
|
5231
|
+
if input.base_contract_hash != *contract_hash {
|
|
5232
|
+
return Err(ApiError(
|
|
5233
|
+
StatusCode::CONFLICT,
|
|
5234
|
+
"STALE_PROPOSAL: base contract does not match active contract".into(),
|
|
5235
|
+
));
|
|
5236
|
+
}
|
|
5237
|
+
if input.proposed_flow.trim().is_empty() || input.base_flow_hash.trim().is_empty() {
|
|
5238
|
+
return Err(ApiError(
|
|
5239
|
+
StatusCode::BAD_REQUEST,
|
|
5240
|
+
"proposed_flow and base_flow_hash are required".into(),
|
|
5241
|
+
));
|
|
5242
|
+
}
|
|
5243
|
+
if input.metadata.get("created_from").and_then(Value::as_str) == Some("module-intent")
|
|
5244
|
+
&& input.understanding.is_none()
|
|
5245
|
+
{
|
|
5246
|
+
return Err(inspection_error(StatusCode::UNPROCESSABLE_ENTITY, "understanding_required", "module proposals require an evidence-backed understanding snapshot"));
|
|
5247
|
+
}
|
|
5248
|
+
if input.understanding.is_some()
|
|
5249
|
+
&& (input.evidence_hash.as_deref().is_none_or(|value| !value.starts_with("sha256:"))
|
|
5250
|
+
|| input.understanding_hash.as_deref().is_none_or(|value| !value.starts_with("sha256:")))
|
|
5251
|
+
{
|
|
5252
|
+
return Err(inspection_error(StatusCode::UNPROCESSABLE_ENTITY, "invalid_understanding", "understanding requires evidence_hash and understanding_hash fingerprints"));
|
|
5253
|
+
}
|
|
5254
|
+
if let Some(understanding) = input.understanding.as_ref() {
|
|
5255
|
+
if understanding.get("evidence_hash").and_then(Value::as_str) != input.evidence_hash.as_deref()
|
|
5256
|
+
|| understanding.get("understanding_hash").and_then(Value::as_str) != input.understanding_hash.as_deref()
|
|
5257
|
+
{
|
|
5258
|
+
return Err(inspection_error(StatusCode::UNPROCESSABLE_ENTITY, "invalid_understanding", "understanding fingerprints must match the Proposal fingerprints"));
|
|
5259
|
+
}
|
|
5260
|
+
}
|
|
5261
|
+
let id = format!("p_{}", uuid::Uuid::new_v4().simple());
|
|
5262
|
+
let now = unix_seconds_i64();
|
|
5263
|
+
let mut value = json!({ "id":id, "application_id":input.application_id, "environment":input.environment,
|
|
5264
|
+
"status":"proposed", "proposal_version":1, "base_contract_version":revision.revision_number,
|
|
5265
|
+
"base_contract_hash":input.base_contract_hash, "base_flow_hash":input.base_flow_hash, "proposed_flow":input.proposed_flow,
|
|
5266
|
+
"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,
|
|
5267
|
+
"created_by":principal.key_id, "created_at":now, "expires_at":input.expires_at, "metadata":input.metadata,
|
|
5268
|
+
"parent_proposal_id":input.parent_proposal_id,
|
|
5269
|
+
"understanding":input.understanding, "evidence_hash":input.evidence_hash, "understanding_hash":input.understanding_hash,
|
|
5270
|
+
"base_module_versions": revision.manifest.modules.iter().filter_map(|module| Some(json!({
|
|
5271
|
+
"id": module.get("id")?, "provider": module.get("provider")?, "version": module.get("version")?
|
|
5272
|
+
}))).collect::<Vec<_>>(),
|
|
5273
|
+
"module_versions": revision.manifest.modules.iter().filter_map(|module| Some(json!({
|
|
5274
|
+
"id": module.get("id")?, "provider": module.get("provider")?, "version": module.get("version")?
|
|
5275
|
+
}))).collect::<Vec<_>>() });
|
|
5276
|
+
redact_inspection_value(&mut value);
|
|
5277
|
+
insert_canonical(&state, PROPOSAL_COLLECTION, &id, value.clone())?;
|
|
5278
|
+
proposal_event(&state, &id, None, "proposed", &principal.key_id)?;
|
|
5279
|
+
Ok((StatusCode::CREATED, Json(value)))
|
|
5280
|
+
}
|
|
5281
|
+
async fn list_proposals(
|
|
5282
|
+
State(state): State<AppState>,
|
|
5283
|
+
Extension(principal): Extension<Principal>,
|
|
5284
|
+
Query(input): Query<ProposalScopeRequest>,
|
|
5285
|
+
) -> Result<Json<Value>, ApiError> {
|
|
5286
|
+
application_scope(
|
|
5287
|
+
&state,
|
|
5288
|
+
&principal.key_id,
|
|
5289
|
+
&input.application_id,
|
|
5290
|
+
"application:read",
|
|
5291
|
+
)?;
|
|
5292
|
+
let limit = input.limit.unwrap_or(DEFAULT_INSPECTION_LIMIT);
|
|
5293
|
+
if limit == 0 || limit > MAX_INSPECTION_LIMIT {
|
|
5294
|
+
return Err(inspection_error(StatusCode::UNPROCESSABLE_ENTITY, "invalid_pagination", format!("limit must be between 1 and {MAX_INSPECTION_LIMIT}")));
|
|
5295
|
+
}
|
|
5296
|
+
if input.status.as_ref().is_some_and(|status| !matches!(status.as_str(), "proposed"|"validated"|"previewed"|"approved"|"applied"|"rejected"|"expired")) {
|
|
5297
|
+
return Err(inspection_error(StatusCode::UNPROCESSABLE_ENTITY, "unsupported_filter", "unsupported proposal status"));
|
|
5298
|
+
}
|
|
5299
|
+
let filter_hash = format!("{}:{}:{}", input.application_id, input.environment, input.status.as_deref().unwrap_or("*"));
|
|
5300
|
+
let state_version = state.db.sequence()?;
|
|
5301
|
+
let mut after = input.cursor.as_deref().map(|cursor| parse_inspection_cursor(cursor, PROPOSAL_COLLECTION, state_version, &filter_hash)).transpose()?;
|
|
5302
|
+
let mut accepted = Vec::new();
|
|
5303
|
+
let mut exhausted = false;
|
|
5304
|
+
while accepted.len() <= limit && !exhausted {
|
|
5305
|
+
let page = state.db.list_collection_page(PROPOSAL_COLLECTION, after.as_deref(), 256)?;
|
|
5306
|
+
if page.len() < 256 { exhausted = true; }
|
|
5307
|
+
if page.is_empty() { break; }
|
|
5308
|
+
for row in page {
|
|
5309
|
+
after = Some(row.key.clone());
|
|
5310
|
+
if !proposal_scope_matches(&row.value, &input.application_id, &input.environment) { continue; }
|
|
5311
|
+
if input.status.as_ref().is_some_and(|status| row.value.get("status").and_then(Value::as_str) != Some(status)) { continue; }
|
|
5312
|
+
let mut value = row.value;
|
|
5313
|
+
redact_inspection_value(&mut value);
|
|
5314
|
+
accepted.push((row.key, value));
|
|
5315
|
+
if accepted.len() > limit { break; }
|
|
5316
|
+
}
|
|
5317
|
+
}
|
|
5318
|
+
let has_more = accepted.len() > limit;
|
|
5319
|
+
let last_key = accepted.get(limit.saturating_sub(1)).map(|entry| entry.0.clone());
|
|
5320
|
+
let proposals = accepted.into_iter().take(limit).map(|entry| entry.1).collect::<Vec<_>>();
|
|
5321
|
+
let next_cursor = if has_more { last_key.map(|key| inspection_cursor(PROPOSAL_COLLECTION, state_version, &key, &filter_hash)) } else { None };
|
|
5322
|
+
Ok(Json(json!({"proposals":proposals,"pagination":{"limit":limit,"nextCursor":next_cursor},"state_version":state_version})))
|
|
5323
|
+
}
|
|
5324
|
+
async fn get_proposal(
|
|
5325
|
+
State(state): State<AppState>,
|
|
5326
|
+
Extension(principal): Extension<Principal>,
|
|
5327
|
+
Path(id): Path<String>,
|
|
5328
|
+
Query(input): Query<ProposalScopeRequest>,
|
|
5329
|
+
) -> Result<Json<Value>, ApiError> {
|
|
5330
|
+
application_scope(
|
|
5331
|
+
&state,
|
|
5332
|
+
&principal.key_id,
|
|
5333
|
+
&input.application_id,
|
|
5334
|
+
"application:read",
|
|
5335
|
+
)?;
|
|
5336
|
+
let mut value = proposal_value(&state, &id)?;
|
|
5337
|
+
if !proposal_scope_matches(&value, &input.application_id, &input.environment) {
|
|
5338
|
+
return Err(ApiError(StatusCode::NOT_FOUND, "proposal not found".into()));
|
|
5339
|
+
}
|
|
5340
|
+
let mut value = value;
|
|
5341
|
+
redact_inspection_value(&mut value);
|
|
5342
|
+
Ok(Json(value))
|
|
5343
|
+
}
|
|
5344
|
+
async fn proposal_history(
|
|
5345
|
+
State(state): State<AppState>,
|
|
5346
|
+
Extension(principal): Extension<Principal>,
|
|
5347
|
+
Path(id): Path<String>,
|
|
5348
|
+
Query(input): Query<ProposalScopeRequest>,
|
|
5349
|
+
) -> Result<Json<Value>, ApiError> {
|
|
5350
|
+
let _ = get_proposal(
|
|
5351
|
+
State(state.clone()),
|
|
5352
|
+
Extension(principal),
|
|
5353
|
+
Path(id.clone()),
|
|
5354
|
+
Query(input),
|
|
5355
|
+
)
|
|
5356
|
+
.await?;
|
|
5357
|
+
let mut events: Vec<Value> = state
|
|
5358
|
+
.db
|
|
5359
|
+
.list_collection(PROPOSAL_EVENT_COLLECTION)?
|
|
5360
|
+
.into_iter()
|
|
5361
|
+
.map(|row| row.value)
|
|
5362
|
+
.filter(|value| value.get("proposal_id").and_then(Value::as_str) == Some(id.as_str()))
|
|
5363
|
+
.collect();
|
|
5364
|
+
events.sort_by(|left, right| left.get("event_sequence").and_then(Value::as_u64).cmp(&right.get("event_sequence").and_then(Value::as_u64)).then_with(|| left.get("id").and_then(Value::as_str).cmp(&right.get("id").and_then(Value::as_str))));
|
|
5365
|
+
for event in &mut events { redact_inspection_value(event); }
|
|
5366
|
+
Ok(Json(json!({"events":events})))
|
|
5367
|
+
}
|
|
5368
|
+
fn move_proposal(
|
|
5369
|
+
state: &AppState,
|
|
5370
|
+
id: &str,
|
|
5371
|
+
mut value: Value,
|
|
5372
|
+
to: &str,
|
|
5373
|
+
actor: &str,
|
|
5374
|
+
) -> Result<Value, ApiError> {
|
|
5375
|
+
let from = value
|
|
5376
|
+
.get("status")
|
|
5377
|
+
.and_then(Value::as_str)
|
|
5378
|
+
.unwrap_or("")
|
|
5379
|
+
.to_string();
|
|
5380
|
+
let allowed = matches!(
|
|
5381
|
+
(from.as_str(), to),
|
|
5382
|
+
("proposed", "validated")
|
|
5383
|
+
| ("proposed", "rejected")
|
|
5384
|
+
| ("validated", "previewed")
|
|
5385
|
+
| ("validated", "rejected")
|
|
5386
|
+
| ("previewed", "approved")
|
|
5387
|
+
| ("previewed", "rejected")
|
|
5388
|
+
| ("approved", "applied")
|
|
5389
|
+
| ("approved", "expired")
|
|
5390
|
+
);
|
|
5391
|
+
if !allowed {
|
|
5392
|
+
return Err(ApiError(
|
|
5393
|
+
StatusCode::CONFLICT,
|
|
5394
|
+
format!("INVALID_PROPOSAL_TRANSITION: {from} -> {to}"),
|
|
5395
|
+
));
|
|
5396
|
+
}
|
|
5397
|
+
value["status"] = json!(to);
|
|
5398
|
+
if to == "approved" {
|
|
5399
|
+
value["approved_by"] = json!(actor);
|
|
5400
|
+
value["approved_at"] = json!(unix_seconds_i64());
|
|
5401
|
+
}
|
|
5402
|
+
if to == "applied" {
|
|
5403
|
+
value["applied_at"] = json!(unix_seconds_i64());
|
|
5404
|
+
}
|
|
5405
|
+
if to == "rejected" {
|
|
5406
|
+
value["rejected_by"] = json!(actor);
|
|
5407
|
+
value["rejected_at"] = json!(unix_seconds_i64());
|
|
5408
|
+
}
|
|
5409
|
+
let _ = put_canonical(state, PROPOSAL_COLLECTION, id, value.clone())?;
|
|
5410
|
+
proposal_event(state, id, Some(&from), to, actor)?;
|
|
5411
|
+
Ok(value)
|
|
5412
|
+
}
|
|
5413
|
+
|
|
5414
|
+
fn proposal_is_expired(value: &Value) -> bool {
|
|
5415
|
+
value.get("expires_at").and_then(|expires| expires.as_i64().or_else(|| expires.as_str()?.parse().ok())).is_some_and(|expires| expires <= unix_seconds_i64())
|
|
5416
|
+
}
|
|
5417
|
+
|
|
5418
|
+
fn proposal_readiness_value(
|
|
5419
|
+
state: &AppState,
|
|
5420
|
+
tenant: &str,
|
|
5421
|
+
value: &Value,
|
|
5422
|
+
current_evidence_hash: Option<&str>,
|
|
5423
|
+
) -> Result<Value, ApiError> {
|
|
5424
|
+
let application_id = value.get("application_id").and_then(Value::as_str).unwrap_or("");
|
|
5425
|
+
let environment = value.get("environment").and_then(Value::as_str).unwrap_or("production");
|
|
5426
|
+
let revision_id = state.applications.pointers(application_id).get(environment).cloned().ok_or_else(|| inspection_error(StatusCode::NOT_FOUND, "application_not_found", "active application contract not found"))?;
|
|
5427
|
+
let revision = state.applications.revision(tenant, application_id, &revision_id).ok_or_else(|| inspection_error(StatusCode::NOT_FOUND, "application_not_found", "active application revision not found"))?;
|
|
5428
|
+
let current_contract_hash = revision.manifest.metadata.labels.get("feltdb.contract_hash").unwrap_or(&revision.manifest_hash).clone();
|
|
5429
|
+
let current_flow_hash = revision.manifest.metadata.labels.get("feltdb.flow_hash").cloned();
|
|
5430
|
+
let proposal_contract_hash = value.get("base_contract_hash").and_then(Value::as_str).unwrap_or("");
|
|
5431
|
+
let proposal_flow_hash = value.get("base_flow_hash").and_then(Value::as_str).unwrap_or("");
|
|
5432
|
+
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
|
+
let contract_changed = current_contract_hash != proposal_contract_hash;
|
|
5434
|
+
let flow_changed = current_flow_hash.as_deref().is_some_and(|hash| hash != proposal_flow_hash);
|
|
5435
|
+
let modules_changed = value.get("base_module_versions").and_then(Value::as_array).is_some_and(|modules| modules != ¤t_modules);
|
|
5436
|
+
let evidence_changed = current_evidence_hash.is_some_and(|current| value.get("evidence_hash").and_then(Value::as_str).is_some_and(|base| base != current));
|
|
5437
|
+
let expired = proposal_is_expired(value);
|
|
5438
|
+
let mut blockers = Vec::new();
|
|
5439
|
+
if contract_changed { blockers.push("contract_changed"); }
|
|
5440
|
+
if flow_changed { blockers.push("flow_changed"); }
|
|
5441
|
+
if modules_changed { blockers.push("module_versions_changed"); }
|
|
5442
|
+
if evidence_changed { blockers.push("evidence_changed"); }
|
|
5443
|
+
if expired { blockers.push("expired"); }
|
|
5444
|
+
let stale = contract_changed || flow_changed || modules_changed || evidence_changed;
|
|
5445
|
+
Ok(json!({
|
|
5446
|
+
"status":value.get("status"), "readiness":if blockers.is_empty(){"ready"}else{"blocked"},
|
|
5447
|
+
"blockers":blockers, "notes":[], "current_contract_hash":current_contract_hash,
|
|
5448
|
+
"proposal_contract_hash":proposal_contract_hash, "current_flow_hash":current_flow_hash,
|
|
5449
|
+
"proposal_flow_hash":proposal_flow_hash, "stale":stale, "expires_at":value.get("expires_at")
|
|
5450
|
+
}))
|
|
5451
|
+
}
|
|
5452
|
+
|
|
5453
|
+
async fn proposal_status(
|
|
5454
|
+
State(state): State<AppState>,
|
|
5455
|
+
Extension(principal): Extension<Principal>,
|
|
5456
|
+
Path(id): Path<String>,
|
|
5457
|
+
Query(input): Query<ProposalScopeRequest>,
|
|
5458
|
+
) -> Result<Json<Value>, ApiError> {
|
|
5459
|
+
let tenant = application_scope(&state, &principal.key_id, &input.application_id, "application:read")?;
|
|
5460
|
+
let mut value = proposal_value(&state, &id)?;
|
|
5461
|
+
if !proposal_scope_matches(&value, &input.application_id, &input.environment) {
|
|
5462
|
+
return Err(inspection_error(StatusCode::NOT_FOUND, "proposal_not_found", "proposal not found"));
|
|
5463
|
+
}
|
|
5464
|
+
Ok(Json(proposal_readiness_value(&state, &tenant, &value, input.current_evidence_hash.as_deref())?))
|
|
5465
|
+
}
|
|
5466
|
+
async fn record_proposal_convergence(
|
|
5467
|
+
State(state): State<AppState>, Extension(principal): Extension<Principal>, Path(id): Path<String>, Json(mut input): Json<Value>,
|
|
5468
|
+
) -> Result<(StatusCode, Json<Value>), ApiError> {
|
|
5469
|
+
let application_id=input.get("application_id").and_then(Value::as_str).ok_or_else(||inspection_error(StatusCode::BAD_REQUEST,"invalid_convergence","application_id is required"))?.to_string();
|
|
5470
|
+
let environment=input.get("environment").and_then(Value::as_str).unwrap_or("production").to_string();
|
|
5471
|
+
application_scope(&state,&principal.key_id,&application_id,"application:write")?;let proposal=proposal_value(&state,&id)?;
|
|
5472
|
+
if !proposal_scope_matches(&proposal,&application_id,&environment){return Err(inspection_error(StatusCode::NOT_FOUND,"proposal_not_found","proposal not found"));}
|
|
5473
|
+
if proposal.get("status").and_then(Value::as_str)!=Some("applied"){return Err(inspection_error(StatusCode::CONFLICT,"proposal_not_applied","only an applied Proposal can record convergence"));}
|
|
5474
|
+
if input.get("proposal_id").and_then(Value::as_str)!=Some(id.as_str()){return Err(inspection_error(StatusCode::UNPROCESSABLE_ENTITY,"invalid_convergence","proposal_id must match the route"));}
|
|
5475
|
+
let valid_status=input.get("status").and_then(Value::as_str).is_some_and(|value|matches!(value,"pending"|"checking"|"converged"|"partially_converged"|"diverged"|"blocked"|"unverified"));
|
|
5476
|
+
if !valid_status||input.get("findings").is_none_or(|value|!value.is_array())||input.get("evidence_hash").and_then(Value::as_str).is_none_or(|value|!value.starts_with("sha256:")){return Err(inspection_error(StatusCode::UNPROCESSABLE_ENTITY,"invalid_convergence","status, findings, and evidence_hash are required"));}
|
|
5477
|
+
let convergence_id=format!("cv_{}",uuid::Uuid::new_v4().simple());input["id"]=json!(convergence_id);input["created_at"]=json!(unix_seconds_i64());input["verifier_version"]=json!(1);
|
|
5478
|
+
redact_inspection_value(&mut input);insert_canonical(&state,CONVERGENCE_COLLECTION,&convergence_id,input.clone())?;Ok((StatusCode::CREATED,Json(input)))
|
|
5479
|
+
}
|
|
5480
|
+
async fn proposal_convergence(
|
|
5481
|
+
State(state): State<AppState>, Extension(principal): Extension<Principal>, Path(id): Path<String>, Query(input): Query<ProposalScopeRequest>,
|
|
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})))
|
|
5487
|
+
}
|
|
5488
|
+
async fn validate_proposal(
|
|
5489
|
+
State(state): State<AppState>,
|
|
5490
|
+
Extension(principal): Extension<Principal>,
|
|
5491
|
+
Path(id): Path<String>,
|
|
5492
|
+
Json(input): Json<ProposalActorRequest>,
|
|
5493
|
+
) -> Result<Json<Value>, ApiError> {
|
|
5494
|
+
let tenant = application_scope(
|
|
5495
|
+
&state,
|
|
5496
|
+
&principal.key_id,
|
|
5497
|
+
&input.application_id,
|
|
5498
|
+
"application:write",
|
|
5499
|
+
)?;
|
|
5500
|
+
let mut value = proposal_value(&state, &id)?;
|
|
5501
|
+
if !proposal_scope_matches(&value, &input.application_id, &input.environment) {
|
|
5502
|
+
return Err(ApiError(StatusCode::NOT_FOUND, "proposal not found".into()));
|
|
5503
|
+
}
|
|
5504
|
+
if value.get("status").and_then(Value::as_str) == Some("validated") {
|
|
5505
|
+
redact_inspection_value(&mut value);
|
|
5506
|
+
return Ok(Json(value));
|
|
5507
|
+
}
|
|
5508
|
+
let revision_id = state
|
|
5509
|
+
.applications
|
|
5510
|
+
.pointers(&input.application_id)
|
|
5511
|
+
.get(&input.environment)
|
|
5512
|
+
.cloned()
|
|
5513
|
+
.ok_or(ApiError(
|
|
5514
|
+
StatusCode::NOT_FOUND,
|
|
5515
|
+
"active contract not found".into(),
|
|
5516
|
+
))?;
|
|
5517
|
+
let revision = state
|
|
5518
|
+
.applications
|
|
5519
|
+
.revision(&tenant, &input.application_id, &revision_id)
|
|
5520
|
+
.ok_or(ApiError(
|
|
5521
|
+
StatusCode::NOT_FOUND,
|
|
5522
|
+
"application revision not found".into(),
|
|
5523
|
+
))?;
|
|
5524
|
+
let hash = revision
|
|
5525
|
+
.manifest
|
|
5526
|
+
.metadata
|
|
5527
|
+
.labels
|
|
5528
|
+
.get("feltdb.contract_hash")
|
|
5529
|
+
.unwrap_or(&revision.manifest_hash);
|
|
5530
|
+
if value.get("base_contract_hash").and_then(Value::as_str) != Some(hash) {
|
|
5531
|
+
return Err(ApiError(StatusCode::CONFLICT, "STALE_PROPOSAL".into()));
|
|
5532
|
+
}
|
|
5533
|
+
if let Some(manifest) = input.proposed_manifest {
|
|
5534
|
+
let report = validate_manifest(
|
|
5535
|
+
&manifest,
|
|
5536
|
+
&tenant,
|
|
5537
|
+
&input.application_id,
|
|
5538
|
+
Some(&revision.manifest),
|
|
5539
|
+
);
|
|
5540
|
+
if !report.valid {
|
|
5541
|
+
return Err(ApiError::structured(
|
|
5542
|
+
StatusCode::UNPROCESSABLE_ENTITY,
|
|
5543
|
+
json!({"code":"INVALID_PROPOSAL","validation":report}),
|
|
5544
|
+
));
|
|
5545
|
+
}
|
|
5546
|
+
value["module_versions"] = json!(manifest.modules.iter().filter_map(|module| Some(json!({
|
|
5547
|
+
"id": module.get("id")?, "provider": module.get("provider")?, "version": module.get("version")?
|
|
5548
|
+
}))).collect::<Vec<_>>());
|
|
5549
|
+
let module_ids = manifest
|
|
5550
|
+
.modules
|
|
5551
|
+
.iter()
|
|
5552
|
+
.filter_map(|module| module.get("id").and_then(Value::as_str))
|
|
5553
|
+
.collect::<Vec<_>>();
|
|
5554
|
+
let module_models = manifest
|
|
5555
|
+
.collections
|
|
5556
|
+
.iter()
|
|
5557
|
+
.flat_map(|collection| collection.fields.iter())
|
|
5558
|
+
.filter_map(|field| field.reference.as_deref())
|
|
5559
|
+
.filter(|reference| {
|
|
5560
|
+
module_ids
|
|
5561
|
+
.iter()
|
|
5562
|
+
.any(|id| reference.starts_with(&format!("{id}.")))
|
|
5563
|
+
})
|
|
5564
|
+
.collect::<Vec<_>>();
|
|
5565
|
+
let relationships = manifest
|
|
5566
|
+
.collections
|
|
5567
|
+
.iter()
|
|
5568
|
+
.flat_map(|collection| {
|
|
5569
|
+
collection.fields.iter().filter_map(|field| {
|
|
5570
|
+
field.reference.as_ref().map(|reference| {
|
|
5571
|
+
format!("{}.{} -> {}", collection.name, field.name, reference)
|
|
5572
|
+
})
|
|
5573
|
+
})
|
|
5574
|
+
})
|
|
5575
|
+
.collect::<Vec<_>>();
|
|
5576
|
+
let ownership = manifest
|
|
5577
|
+
.modules
|
|
5578
|
+
.iter()
|
|
5579
|
+
.flat_map(|module| {
|
|
5580
|
+
let id = module.get("id").and_then(Value::as_str).unwrap_or("");
|
|
5581
|
+
module
|
|
5582
|
+
.get("models")
|
|
5583
|
+
.and_then(Value::as_array)
|
|
5584
|
+
.into_iter()
|
|
5585
|
+
.flatten()
|
|
5586
|
+
.filter_map(move |model| {
|
|
5587
|
+
Some((
|
|
5588
|
+
format!("{}.{}", id, model.get("name")?.as_str()?),
|
|
5589
|
+
Value::String(model.get("ownership")?.as_str()?.to_string()),
|
|
5590
|
+
))
|
|
5591
|
+
})
|
|
5592
|
+
})
|
|
5593
|
+
.collect::<serde_json::Map<String, Value>>();
|
|
5594
|
+
value["module_impact"] = json!({
|
|
5595
|
+
"modules":module_ids, "models":module_models, "relationships":relationships,
|
|
5596
|
+
"events":manifest.triggers.iter().filter(|trigger| module_ids.iter().any(|id| trigger.event.starts_with(&format!("{id}.")))).map(|trigger|trigger.event.clone()).collect::<Vec<_>>(),
|
|
5597
|
+
"workflows":manifest.workflows.iter().map(|workflow|workflow.name.clone()).collect::<Vec<_>>(),
|
|
5598
|
+
"capabilities":manifest.capabilities.iter().filter(|capability| capability.provider.as_deref().map(|provider|module_ids.contains(&provider)).unwrap_or(false)).map(|capability|capability.name.clone()).collect::<Vec<_>>(),
|
|
5599
|
+
"authorization":manifest.policies.iter().map(|policy|policy.name.clone()).collect::<Vec<_>>(),
|
|
5600
|
+
"configuration":manifest.modules.iter().flat_map(|module| module.get("configuration").and_then(Value::as_array).into_iter().flatten().filter_map(|item|item.get("name").cloned())).collect::<Vec<_>>(),
|
|
5601
|
+
"secrets":manifest.modules.iter().flat_map(|module| module.get("secrets").and_then(Value::as_array).into_iter().flatten().filter_map(|item|item.get("name").cloned())).collect::<Vec<_>>(),
|
|
5602
|
+
"ownership":ownership,
|
|
5603
|
+
"state_flow":manifest.modules.iter().flat_map(|module| module.get("reconciliation").and_then(Value::as_array).into_iter().flatten().filter_map(|item| Some(format!("{} -> {} -> {}",item.get("event")?.as_str()?,item.get("workflow")?.as_str()?,item.get("outcome")?.as_str()?)))).collect::<Vec<_>>()
|
|
5604
|
+
});
|
|
5605
|
+
let proposed = ApplicationRevision {
|
|
5606
|
+
revision_id: format!("proposal://{id}"),
|
|
5607
|
+
application_id: input.application_id.clone(),
|
|
5608
|
+
tenant_id: tenant,
|
|
5609
|
+
revision_number: revision.revision_number + 1,
|
|
5610
|
+
parent_revision_id: Some(revision.revision_id.clone()),
|
|
5611
|
+
manifest_hash: String::new(),
|
|
5612
|
+
manifest,
|
|
5613
|
+
created_by: principal.key_id.clone(),
|
|
5614
|
+
created_at: unix_seconds_i64() as u64,
|
|
5615
|
+
status: RevisionStatus::Committed,
|
|
5616
|
+
};
|
|
5617
|
+
value["contract_diff"] =
|
|
5618
|
+
serde_json::to_value(diff_manifests(Some(&revision), &proposed).changes)
|
|
5619
|
+
.map_err(|e| ApiError(StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
|
5620
|
+
value["proposed_contract"] = serde_json::to_value(&proposed.manifest)
|
|
5621
|
+
.map_err(|e| ApiError(StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
|
5622
|
+
}
|
|
5623
|
+
if !value.pointer("/source_plan/files").and_then(Value::as_array).is_some_and(|files| !files.is_empty()) {
|
|
5624
|
+
value["source_plan"] = json!({"files":[{"path":"feltdb.flow","operation":"modify"},{"path":"src/feltdb/generated-contract.ts","operation":"modify"}]});
|
|
5625
|
+
}
|
|
5626
|
+
Ok(Json(move_proposal(
|
|
5627
|
+
&state,
|
|
5628
|
+
&id,
|
|
5629
|
+
value,
|
|
5630
|
+
"validated",
|
|
5631
|
+
&principal.key_id,
|
|
5632
|
+
)?))
|
|
5633
|
+
}
|
|
5634
|
+
async fn preview_proposal(
|
|
5635
|
+
State(state): State<AppState>,
|
|
5636
|
+
Extension(principal): Extension<Principal>,
|
|
5637
|
+
Path(id): Path<String>,
|
|
5638
|
+
Json(input): Json<ProposalActorRequest>,
|
|
5639
|
+
) -> Result<Json<Value>, ApiError> {
|
|
5640
|
+
let tenant = application_scope(
|
|
5641
|
+
&state,
|
|
5642
|
+
&principal.key_id,
|
|
5643
|
+
&input.application_id,
|
|
5644
|
+
"application:write",
|
|
5645
|
+
)?;
|
|
5646
|
+
let mut value = proposal_value(&state, &id)?;
|
|
5647
|
+
if !proposal_scope_matches(&value, &input.application_id, &input.environment) {
|
|
5648
|
+
return Err(ApiError(StatusCode::NOT_FOUND, "proposal not found".into()));
|
|
5649
|
+
}
|
|
5650
|
+
let replacing_expired_preview = value.get("status").and_then(Value::as_str) == Some("previewed");
|
|
5651
|
+
if replacing_expired_preview {
|
|
5652
|
+
if let Some(preview_id) = value.get("preview_id").and_then(Value::as_str) {
|
|
5653
|
+
let mut artifact = preview_artifact(&state, preview_id)?;
|
|
5654
|
+
if preview_available(&artifact) {
|
|
5655
|
+
let mut proposal = value.clone();
|
|
5656
|
+
redact_inspection_value(&mut proposal);
|
|
5657
|
+
redact_inspection_value(&mut artifact);
|
|
5658
|
+
if let Some(object) = artifact.as_object_mut() { object.remove("namespace"); }
|
|
5659
|
+
artifact["proposal"] = proposal;
|
|
5660
|
+
return Ok(Json(artifact));
|
|
5661
|
+
}
|
|
5662
|
+
}
|
|
5663
|
+
}
|
|
5664
|
+
if !replacing_expired_preview && value.get("status").and_then(Value::as_str) != Some("validated") {
|
|
5665
|
+
return Err(inspection_error(StatusCode::CONFLICT, "invalid_proposal_transition", "only a validated proposal can be previewed"));
|
|
5666
|
+
}
|
|
5667
|
+
let proposed_manifest: ApplicationManifest = serde_json::from_value(value.get("proposed_contract").cloned().ok_or_else(|| inspection_error(StatusCode::UNPROCESSABLE_ENTITY, "missing_required_semantics", "validated proposal has no proposed contract snapshot"))?).map_err(|error| inspection_error(StatusCode::UNPROCESSABLE_ENTITY, "invalid_proposal", error.to_string()))?;
|
|
5668
|
+
let current_revision_id = state.applications.pointers(&input.application_id).get(&input.environment).cloned().ok_or_else(|| inspection_error(StatusCode::NOT_FOUND, "application_not_found", "active application contract not found"))?;
|
|
5669
|
+
let current_revision = state.applications.revision(&tenant, &input.application_id, ¤t_revision_id).ok_or_else(|| inspection_error(StatusCode::NOT_FOUND, "application_not_found", "active application revision not found"))?;
|
|
5670
|
+
let readiness = proposal_readiness_value(&state, &tenant, &value, None)?;
|
|
5671
|
+
if readiness.get("readiness").and_then(Value::as_str) != Some("ready") {
|
|
5672
|
+
return Err(ApiError::structured(StatusCode::CONFLICT, json!({"code":"preview_not_ready","message":"proposal cannot be previewed","readiness":readiness})));
|
|
5673
|
+
}
|
|
5674
|
+
let preview_id = format!("pv_{}", uuid::Uuid::new_v4().simple());
|
|
5675
|
+
let preview_environment = format!("preview/proposal/{preview_id}");
|
|
5676
|
+
let proposed_manifest_hash = manifest_hash(&proposed_manifest).map_err(|error| inspection_error(StatusCode::UNPROCESSABLE_ENTITY, "preview_contract_invalid", error))?;
|
|
5677
|
+
let proposed_revision = ApplicationRevision { revision_id:format!("proposal://{id}"), application_id:input.application_id.clone(), tenant_id:tenant,
|
|
5678
|
+
revision_number:value.get("base_contract_version").and_then(Value::as_u64).unwrap_or(current_revision.revision_number)+1,
|
|
5679
|
+
parent_revision_id:Some(current_revision.revision_id.clone()), manifest_hash:proposed_manifest_hash, manifest:proposed_manifest,
|
|
5680
|
+
created_by:principal.key_id.clone(), created_at:unix_seconds_i64() as u64, status:RevisionStatus::Committed };
|
|
5681
|
+
let runtime = resolve_runtime(&proposed_revision, &preview_environment, &RuntimeInventory::default()).map_err(|report| ApiError::structured(StatusCode::UNPROCESSABLE_ENTITY, json!({"code":"preview_contract_invalid","validation":report})))?;
|
|
5682
|
+
let current_contract = proposal_contract_snapshot(¤t_revision, &input.environment);
|
|
5683
|
+
let mut proposed_contract = proposal_contract_snapshot(&proposed_revision, &input.environment);
|
|
5684
|
+
proposed_contract["contract_hash"] = json!(proposal_hash(&proposed_contract));
|
|
5685
|
+
proposed_contract["flow_hash"] = json!(format!("sha256:{:x}", Sha256::digest(value.get("proposed_flow").and_then(Value::as_str).unwrap_or("").replace("\r\n", "\n").as_bytes())));
|
|
5686
|
+
let namespace = runtime.environment.state_namespace.clone();
|
|
5687
|
+
let mut seeded_records = Vec::new();
|
|
5688
|
+
for collection in &proposed_revision.manifest.collections {
|
|
5689
|
+
if collection.name.starts_with("_feltdb") { continue; }
|
|
5690
|
+
let record_id = format!("preview_{}", collection.name.to_ascii_lowercase());
|
|
5691
|
+
let record = json!({"id":record_id,"_preview_data":true,"_preview_source":"synthetic","label":format!("Synthetic {} preview",collection.name)});
|
|
5692
|
+
state.db.capability(&format!("{}:{}", namespace, collection.name)).insert_with_key(&record_id, record)?;
|
|
5693
|
+
seeded_records.push(json!({"collection":collection.name,"id":record_id,"preview_data":true}));
|
|
5694
|
+
}
|
|
5695
|
+
let now = unix_seconds_i64();
|
|
5696
|
+
let mut artifact = json!({
|
|
5697
|
+
"preview_id":preview_id,"proposal_id":id,"application_id":input.application_id,"environment":input.environment,
|
|
5698
|
+
"proposal_hash":proposal_hash(&value),"proposal_contract_hash":value.get("base_contract_hash"),"proposal_flow_hash":value.get("base_flow_hash"),
|
|
5699
|
+
"module_versions":value.get("module_versions"),"created_at":now,"expires_at":now+preview_ttl_seconds(),"status":"ready",
|
|
5700
|
+
"namespace":namespace,"current":current_contract,"proposed":proposed_contract,"current_contract":current_contract,
|
|
5701
|
+
"proposed_contract":proposed_contract,"semantic_diff":value.get("contract_diff"),"state_flow":value.pointer("/module_impact/state_flow"),
|
|
5702
|
+
"capabilities":runtime.capabilities,"workflows":runtime.workflows,"authorization":runtime.policies,
|
|
5703
|
+
"configuration":value.pointer("/module_impact/configuration"),"secrets":value.pointer("/module_impact/secrets"),
|
|
5704
|
+
"modules":value.pointer("/module_impact/modules"),"ownership":value.pointer("/module_impact/ownership"),
|
|
5705
|
+
"relationships":value.pointer("/module_impact/relationships"),"events":value.pointer("/module_impact/events"),
|
|
5706
|
+
"seeded_records":seeded_records,"data_classification":"synthetic_preview","external_side_effects":"simulated","readiness":readiness
|
|
5707
|
+
});
|
|
5708
|
+
redact_inspection_value(&mut artifact);
|
|
5709
|
+
insert_canonical(&state, PROPOSAL_PREVIEW_COLLECTION, artifact.get("preview_id").and_then(Value::as_str).unwrap_or(""), artifact.clone())?;
|
|
5710
|
+
value["preview_id"] = artifact.get("preview_id").cloned().unwrap_or(Value::Null);
|
|
5711
|
+
value["preview_expires_at"] = artifact.get("expires_at").cloned().unwrap_or(Value::Null);
|
|
5712
|
+
let mut stored_proposal = if replacing_expired_preview {
|
|
5713
|
+
let _ = put_canonical(&state, PROPOSAL_COLLECTION, &id, value.clone())?;
|
|
5714
|
+
value
|
|
5715
|
+
} else {
|
|
5716
|
+
move_proposal(&state, &id, value, "previewed", &principal.key_id)?
|
|
5717
|
+
};
|
|
5718
|
+
redact_inspection_value(&mut stored_proposal);
|
|
5719
|
+
if let Some(object) = artifact.as_object_mut() { object.remove("namespace"); }
|
|
5720
|
+
artifact["proposal"] = stored_proposal;
|
|
5721
|
+
Ok(Json(artifact))
|
|
5722
|
+
}
|
|
5723
|
+
|
|
5724
|
+
async fn get_proposal_preview(
|
|
5725
|
+
State(state): State<AppState>, Extension(principal): Extension<Principal>, Path(id): Path<String>,
|
|
5726
|
+
Query(input): Query<ProposalScopeRequest>,
|
|
5727
|
+
) -> Result<Json<Value>, ApiError> {
|
|
5728
|
+
let tenant = application_scope(&state, &principal.key_id, &input.application_id, "application:read")?;
|
|
5729
|
+
let mut artifact = preview_artifact(&state, &id)?;
|
|
5730
|
+
if !proposal_scope_matches(&artifact, &input.application_id, &input.environment) {
|
|
5731
|
+
return Err(inspection_error(StatusCode::NOT_FOUND, "preview_not_found", "preview not found"));
|
|
5732
|
+
}
|
|
5733
|
+
if !preview_available(&artifact) {
|
|
5734
|
+
return Err(inspection_error(StatusCode::GONE, "preview_expired", "preview artifact has expired"));
|
|
5735
|
+
}
|
|
5736
|
+
let proposal = proposal_value(&state, artifact.get("proposal_id").and_then(Value::as_str).unwrap_or(""))?;
|
|
5737
|
+
if proposal.get("preview_id") != artifact.get("preview_id") {
|
|
5738
|
+
return Err(inspection_error(StatusCode::CONFLICT, "preview_invalidated", "preview has been replaced"));
|
|
5739
|
+
}
|
|
5740
|
+
if proposal_readiness_value(&state, &tenant, &proposal, None)?.get("stale").and_then(Value::as_bool) == Some(true) {
|
|
5741
|
+
return Err(inspection_error(StatusCode::CONFLICT, "preview_invalidated", "the application authority changed after preview creation"));
|
|
5742
|
+
}
|
|
5743
|
+
redact_inspection_value(&mut artifact);
|
|
5744
|
+
if let Some(object) = artifact.as_object_mut() { object.remove("namespace"); }
|
|
5745
|
+
Ok(Json(artifact))
|
|
5746
|
+
}
|
|
5747
|
+
|
|
5748
|
+
async fn inspect_proposal_preview_data(
|
|
5749
|
+
State(state): State<AppState>, Extension(principal): Extension<Principal>, Path((id, collection)): Path<(String, String)>,
|
|
5750
|
+
Query(input): Query<ProposalScopeRequest>,
|
|
5751
|
+
) -> Result<Json<Value>, ApiError> {
|
|
5752
|
+
application_scope(&state, &principal.key_id, &input.application_id, "application:read")?;
|
|
5753
|
+
let artifact = preview_artifact(&state, &id)?;
|
|
5754
|
+
if !proposal_scope_matches(&artifact, &input.application_id, &input.environment) {
|
|
5755
|
+
return Err(inspection_error(StatusCode::NOT_FOUND, "preview_not_found", "preview not found"));
|
|
5756
|
+
}
|
|
5757
|
+
if !preview_available(&artifact) { return Err(inspection_error(StatusCode::GONE, "preview_expired", "preview artifact has expired")); }
|
|
5758
|
+
if collection.starts_with("_feltdb") || !artifact.pointer("/proposed/schema").and_then(Value::as_array).is_some_and(|collections| collections.iter().any(|value| value.get("name").and_then(Value::as_str)==Some(&collection))) {
|
|
5759
|
+
return Err(inspection_error(StatusCode::NOT_FOUND, "collection_not_found", "collection is not part of the proposed contract"));
|
|
5760
|
+
}
|
|
5761
|
+
let limit = input.limit.unwrap_or(DEFAULT_INSPECTION_LIMIT);
|
|
5762
|
+
if limit == 0 || limit > MAX_INSPECTION_LIMIT { return Err(inspection_error(StatusCode::UNPROCESSABLE_ENTITY, "invalid_pagination", format!("limit must be between 1 and {MAX_INSPECTION_LIMIT}"))); }
|
|
5763
|
+
let state_version = state.db.sequence()?;
|
|
5764
|
+
let filter_hash = format!("{id}:{collection}");
|
|
5765
|
+
let after = input.cursor.as_deref().map(|cursor| parse_inspection_cursor(cursor, &filter_hash, state_version, "preview-data")).transpose()?;
|
|
5766
|
+
let capability = format!("{}:{}", artifact.get("namespace").and_then(Value::as_str).unwrap_or(""), collection);
|
|
5767
|
+
let mut rows = state.db.list_collection_page(&capability, after.as_deref(), limit.saturating_add(1))?;
|
|
5768
|
+
let has_more = rows.len() > limit;
|
|
5769
|
+
rows.truncate(limit);
|
|
5770
|
+
let last_key = rows.last().map(|row| row.key.clone());
|
|
5771
|
+
let mut records = rows.into_iter().map(|row| row.value).collect::<Vec<_>>();
|
|
5772
|
+
for record in &mut records { redact_inspection_value(record); }
|
|
5773
|
+
let next_cursor = if has_more { last_key.map(|key| inspection_cursor(&filter_hash, state_version, &key, "preview-data")) } else { None };
|
|
5774
|
+
Ok(Json(json!({"preview_id":id,"collection":collection,"records":records,"data_classification":"synthetic_preview","pagination":{"limit":limit,"nextCursor":next_cursor}})))
|
|
5775
|
+
}
|
|
5776
|
+
|
|
5777
|
+
async fn simulate_proposal_preview(
|
|
5778
|
+
State(state): State<AppState>, Extension(principal): Extension<Principal>, Path(id): Path<String>,
|
|
5779
|
+
Query(input): Query<ProposalScopeRequest>, Json(simulation): Json<PreviewSimulationRequest>,
|
|
5780
|
+
) -> Result<Json<Value>, ApiError> {
|
|
5781
|
+
application_scope(&state, &principal.key_id, &input.application_id, "application:write")?;
|
|
5782
|
+
let artifact = preview_artifact(&state, &id)?;
|
|
5783
|
+
if !proposal_scope_matches(&artifact, &input.application_id, &input.environment) { return Err(inspection_error(StatusCode::NOT_FOUND, "preview_not_found", "preview not found")); }
|
|
5784
|
+
if !preview_available(&artifact) { return Err(inspection_error(StatusCode::GONE, "preview_expired", "preview artifact has expired")); }
|
|
5785
|
+
let known = artifact.get("events").and_then(Value::as_array).is_some_and(|events| events.iter().any(|event| event.as_str()==Some(&simulation.event)));
|
|
5786
|
+
if !known { return Err(inspection_error(StatusCode::UNPROCESSABLE_ENTITY, "unsupported_preview_event", "event is not part of the proposal")); }
|
|
5787
|
+
let state_flow = artifact.get("state_flow").cloned().unwrap_or_else(||json!([]));
|
|
5788
|
+
Ok(Json(json!({"preview_id":id,"event":simulation.event,"status":"simulated","external_side_effects":"none","trace":state_flow,"writes":"isolated_preview_namespace_only"})))
|
|
5789
|
+
}
|
|
5790
|
+
async fn transition_proposal(
|
|
5791
|
+
State(state): State<AppState>,
|
|
5792
|
+
Extension(principal): Extension<Principal>,
|
|
5793
|
+
headers: HeaderMap,
|
|
5794
|
+
Path(id): Path<String>,
|
|
5795
|
+
Json(input): Json<ProposalStatusRequest>,
|
|
5796
|
+
) -> Result<Json<Value>, ApiError> {
|
|
5797
|
+
let tenant = application_scope(
|
|
5798
|
+
&state,
|
|
5799
|
+
&principal.key_id,
|
|
5800
|
+
&input.application_id,
|
|
5801
|
+
"application:write",
|
|
5802
|
+
)?;
|
|
5803
|
+
let mut value = proposal_value(&state, &id)?;
|
|
5804
|
+
if !proposal_scope_matches(&value, &input.application_id, &input.environment) {
|
|
5805
|
+
return Err(ApiError(StatusCode::NOT_FOUND, "proposal not found".into()));
|
|
5806
|
+
}
|
|
5807
|
+
if !matches!(
|
|
5808
|
+
input.status.as_str(),
|
|
5809
|
+
"approved" | "rejected" | "expired" | "applied"
|
|
5810
|
+
) {
|
|
5811
|
+
return Err(ApiError(
|
|
5812
|
+
StatusCode::BAD_REQUEST,
|
|
5813
|
+
"unsupported proposal status".into(),
|
|
5814
|
+
));
|
|
5815
|
+
}
|
|
5816
|
+
if proposal_is_expired(&value) && input.status != "expired" {
|
|
5817
|
+
return Err(inspection_error(StatusCode::CONFLICT, "proposal_expired", "proposal has expired"));
|
|
5818
|
+
}
|
|
5819
|
+
if input.status == "approved" {
|
|
5820
|
+
let readiness = proposal_readiness_value(&state, &tenant, &value, None)?;
|
|
5821
|
+
if readiness.get("readiness").and_then(Value::as_str) != Some("ready") {
|
|
5822
|
+
return Err(ApiError::structured(StatusCode::CONFLICT, json!({"code":"stale_proposal","message":"proposal is no longer applicable","readiness":readiness})));
|
|
5823
|
+
}
|
|
5824
|
+
let preview_id = value.get("preview_id").and_then(Value::as_str).ok_or_else(|| inspection_error(StatusCode::CONFLICT, "preview_required", "approval requires a ready preview artifact"))?;
|
|
5825
|
+
let artifact = preview_artifact(&state, preview_id)?;
|
|
5826
|
+
let bound = preview_available(&artifact)
|
|
5827
|
+
&& artifact.get("proposal_id").and_then(Value::as_str) == Some(id.as_str())
|
|
5828
|
+
&& artifact.get("proposal_contract_hash") == value.get("base_contract_hash")
|
|
5829
|
+
&& artifact.get("proposal_flow_hash") == value.get("base_flow_hash")
|
|
5830
|
+
&& artifact.get("module_versions") == value.get("module_versions");
|
|
5831
|
+
if !bound { return Err(inspection_error(StatusCode::CONFLICT, "preview_invalid", "approval requires a current preview bound to this proposal")); }
|
|
5832
|
+
}
|
|
5833
|
+
if input.status == "applied" {
|
|
5834
|
+
let configured = std::env::var("FELTDB_PROPOSAL_AUTHORITY_TOKEN").ok();
|
|
5835
|
+
let supplied = headers.get("FeltDB-Proposal-Authority").and_then(|value| value.to_str().ok());
|
|
5836
|
+
let repository_apply = headers.get("FeltDB-Repository-Apply").and_then(|value| value.to_str().ok()) == Some("1");
|
|
5837
|
+
let authorized = configured.as_deref().zip(supplied).is_some_and(|(expected, actual)| expected.len() == actual.len() && ring::constant_time::verify_slices_are_equal(expected.as_bytes(), actual.as_bytes()).is_ok());
|
|
5838
|
+
if !repository_apply || !authorized {
|
|
5839
|
+
return Err(inspection_error(StatusCode::FORBIDDEN, "repository_authority_required", "valid repository application authority is required"));
|
|
5840
|
+
}
|
|
5841
|
+
}
|
|
5842
|
+
if input.status == "approved"
|
|
5843
|
+
&& value
|
|
5844
|
+
.get("warnings")
|
|
5845
|
+
.and_then(Value::as_array)
|
|
5846
|
+
.map(|items| {
|
|
5847
|
+
items
|
|
5848
|
+
.iter()
|
|
5849
|
+
.any(|item| item.as_str().unwrap_or("").starts_with("Authorization "))
|
|
5850
|
+
})
|
|
5851
|
+
.unwrap_or(false)
|
|
5852
|
+
&& !input.approve_authorization
|
|
5853
|
+
{
|
|
5854
|
+
return Err(ApiError(
|
|
5855
|
+
StatusCode::FORBIDDEN,
|
|
5856
|
+
"AUTHORIZATION_APPROVAL_REQUIRED".into(),
|
|
5857
|
+
));
|
|
5858
|
+
}
|
|
5859
|
+
if input.status == "rejected" {
|
|
5860
|
+
if let Some(reason) = input.reason { value["rejection_reason"] = json!(reason); }
|
|
5861
|
+
}
|
|
5862
|
+
let mut moved = move_proposal(
|
|
5863
|
+
&state,
|
|
5864
|
+
&id,
|
|
5865
|
+
value,
|
|
5866
|
+
&input.status,
|
|
5867
|
+
&principal.key_id,
|
|
5868
|
+
)?;
|
|
5869
|
+
redact_inspection_value(&mut moved);
|
|
5870
|
+
Ok(Json(moved))
|
|
4448
5871
|
}
|
|
4449
|
-
|
|
4450
|
-
|
|
4451
|
-
|
|
4452
|
-
|
|
5872
|
+
|
|
5873
|
+
async fn approve_proposal(
|
|
5874
|
+
state: State<AppState>, principal: Extension<Principal>, headers: HeaderMap,
|
|
5875
|
+
path: Path<String>, Json(input): Json<ProposalLifecycleRequest>,
|
|
4453
5876
|
) -> Result<Json<Value>, ApiError> {
|
|
4454
|
-
|
|
4455
|
-
|
|
4456
|
-
.
|
|
4457
|
-
|
|
4458
|
-
let revision = state.applications.revision(&tenant, &input.application_id, &revision_id)
|
|
4459
|
-
.ok_or(ApiError(StatusCode::NOT_FOUND, "application revision not found".into()))?;
|
|
4460
|
-
let contract_hash = revision.manifest.metadata.labels.get("feltdb.contract_hash").unwrap_or(&revision.manifest_hash);
|
|
4461
|
-
let dsl_version = revision.manifest.metadata.labels.get("feltdb.dsl_version").and_then(|value| value.parse::<u32>().ok()).unwrap_or(1);
|
|
4462
|
-
Ok(Json(json!({
|
|
4463
|
-
"application_id": input.application_id,
|
|
4464
|
-
"environment": input.environment,
|
|
4465
|
-
"version": revision.revision_number,
|
|
4466
|
-
"revision_id": revision.revision_id,
|
|
4467
|
-
"contract_hash": contract_hash,
|
|
4468
|
-
"dsl_version": dsl_version,
|
|
4469
|
-
"snapshot": {
|
|
4470
|
-
"application": { "application_id": input.application_id, "environment": input.environment },
|
|
4471
|
-
"contract_version": revision.revision_number,
|
|
4472
|
-
"contract_hash": contract_hash,
|
|
4473
|
-
"dsl_version": dsl_version,
|
|
4474
|
-
"schema": revision.manifest.collections,
|
|
4475
|
-
"indexes": revision.manifest.indexes,
|
|
4476
|
-
"policies": revision.manifest.policies,
|
|
4477
|
-
"workflows": revision.manifest.workflows,
|
|
4478
|
-
"agents": revision.manifest.agents,
|
|
4479
|
-
"capabilities": revision.manifest.capabilities,
|
|
4480
|
-
"supported_primitives": ["text","integer","number","boolean","datetime","date","time","json","uuid","decimal","money","bigint","email","url","phone","binary","file","geo","object"]
|
|
4481
|
-
}
|
|
4482
|
-
})))
|
|
5877
|
+
transition_proposal(state, principal, headers, path, Json(ProposalStatusRequest {
|
|
5878
|
+
application_id: input.application_id, environment: input.environment, status: "approved".into(),
|
|
5879
|
+
approve_authorization: input.approve_authorization, reason: input.reason,
|
|
5880
|
+
})).await
|
|
4483
5881
|
}
|
|
4484
|
-
const PROPOSAL_COLLECTION: &str = "_feltdb_proposals";
|
|
4485
|
-
const PROPOSAL_EVENT_COLLECTION: &str = "_feltdb_proposal_events";
|
|
4486
5882
|
|
|
4487
|
-
fn
|
|
4488
|
-
|
|
4489
|
-
|
|
4490
|
-
|
|
4491
|
-
|
|
4492
|
-
|
|
4493
|
-
|
|
4494
|
-
|
|
4495
|
-
}
|
|
4496
|
-
fn proposal_scope_matches(value: &Value, application_id: &str, environment: &str) -> bool {
|
|
4497
|
-
value.get("application_id").and_then(Value::as_str) == Some(application_id)
|
|
4498
|
-
&& value.get("environment").and_then(Value::as_str) == Some(environment)
|
|
4499
|
-
}
|
|
4500
|
-
async fn create_proposal(State(state): State<AppState>, Extension(principal): Extension<Principal>, Json(input): Json<CreateProposalRequest>) -> Result<(StatusCode, Json<Value>), ApiError> {
|
|
4501
|
-
let tenant = application_scope(&state, &principal.key_id, &input.application_id, "application:write")?;
|
|
4502
|
-
let revision_id = state.applications.pointers(&input.application_id).get(&input.environment).cloned()
|
|
4503
|
-
.ok_or(ApiError(StatusCode::NOT_FOUND, "application environment has no active contract".into()))?;
|
|
4504
|
-
let revision = state.applications.revision(&tenant, &input.application_id, &revision_id)
|
|
4505
|
-
.ok_or(ApiError(StatusCode::NOT_FOUND, "application revision not found".into()))?;
|
|
4506
|
-
let contract_hash = revision.manifest.metadata.labels.get("feltdb.contract_hash").unwrap_or(&revision.manifest_hash);
|
|
4507
|
-
if input.base_contract_hash != *contract_hash { return Err(ApiError(StatusCode::CONFLICT, "STALE_PROPOSAL: base contract does not match active contract".into())); }
|
|
4508
|
-
if input.proposed_flow.trim().is_empty() || input.base_flow_hash.trim().is_empty() { return Err(ApiError(StatusCode::BAD_REQUEST, "proposed_flow and base_flow_hash are required".into())); }
|
|
4509
|
-
let id = format!("p_{}", uuid::Uuid::new_v4().simple()); let now = unix_seconds_i64();
|
|
4510
|
-
let value = json!({ "id":id, "application_id":input.application_id, "environment":input.environment,
|
|
4511
|
-
"status":"proposed", "proposal_version":1, "base_contract_version":revision.revision_number,
|
|
4512
|
-
"base_contract_hash":input.base_contract_hash, "base_flow_hash":input.base_flow_hash, "proposed_flow":input.proposed_flow,
|
|
4513
|
-
"contract_diff":[], "source_plan":{"files":[]}, "summary":input.summary, "warnings":input.warnings,
|
|
4514
|
-
"created_by":principal.key_id, "created_at":now, "expires_at":input.expires_at, "metadata":input.metadata,
|
|
4515
|
-
"parent_proposal_id":input.parent_proposal_id });
|
|
4516
|
-
insert_canonical(&state, PROPOSAL_COLLECTION, &id, value.clone())?; proposal_event(&state, &id, None, "proposed", &principal.key_id)?;
|
|
4517
|
-
Ok((StatusCode::CREATED, Json(value)))
|
|
4518
|
-
}
|
|
4519
|
-
async fn list_proposals(State(state): State<AppState>, Extension(principal): Extension<Principal>, Query(input): Query<ProposalScopeRequest>) -> Result<Json<Value>, ApiError> {
|
|
4520
|
-
application_scope(&state, &principal.key_id, &input.application_id, "application:read")?;
|
|
4521
|
-
let proposals: Vec<Value> = state.db.list_collection(PROPOSAL_COLLECTION)?.into_iter().map(|row| row.value)
|
|
4522
|
-
.filter(|value| proposal_scope_matches(value, &input.application_id, &input.environment)).collect();
|
|
4523
|
-
Ok(Json(json!({"proposals":proposals})))
|
|
4524
|
-
}
|
|
4525
|
-
async fn get_proposal(State(state): State<AppState>, Extension(principal): Extension<Principal>, Path(id): Path<String>, Query(input): Query<ProposalScopeRequest>) -> Result<Json<Value>, ApiError> {
|
|
4526
|
-
application_scope(&state, &principal.key_id, &input.application_id, "application:read")?; let value = proposal_value(&state, &id)?;
|
|
4527
|
-
if !proposal_scope_matches(&value, &input.application_id, &input.environment) { return Err(ApiError(StatusCode::NOT_FOUND, "proposal not found".into())); }
|
|
4528
|
-
Ok(Json(value))
|
|
4529
|
-
}
|
|
4530
|
-
async fn proposal_history(State(state): State<AppState>, Extension(principal): Extension<Principal>, Path(id): Path<String>, Query(input): Query<ProposalScopeRequest>) -> Result<Json<Value>, ApiError> {
|
|
4531
|
-
let _ = get_proposal(State(state.clone()), Extension(principal), Path(id.clone()), Query(input)).await?;
|
|
4532
|
-
let events: Vec<Value> = state.db.list_collection(PROPOSAL_EVENT_COLLECTION)?.into_iter().map(|row| row.value)
|
|
4533
|
-
.filter(|value| value.get("proposal_id").and_then(Value::as_str) == Some(id.as_str())).collect();
|
|
4534
|
-
Ok(Json(json!({"events":events})))
|
|
5883
|
+
async fn reject_proposal(
|
|
5884
|
+
state: State<AppState>, principal: Extension<Principal>, headers: HeaderMap,
|
|
5885
|
+
path: Path<String>, Json(input): Json<ProposalLifecycleRequest>,
|
|
5886
|
+
) -> Result<Json<Value>, ApiError> {
|
|
5887
|
+
transition_proposal(state, principal, headers, path, Json(ProposalStatusRequest {
|
|
5888
|
+
application_id: input.application_id, environment: input.environment, status: "rejected".into(),
|
|
5889
|
+
approve_authorization: false, reason: input.reason,
|
|
5890
|
+
})).await
|
|
4535
5891
|
}
|
|
4536
|
-
fn
|
|
4537
|
-
|
|
4538
|
-
|
|
4539
|
-
|
|
4540
|
-
|
|
4541
|
-
if to == "applied" { value["applied_at"] = json!(unix_seconds_i64()); }
|
|
4542
|
-
let _ = put_canonical(state, PROPOSAL_COLLECTION, id, value.clone())?; proposal_event(state, id, Some(&from), to, actor)?; Ok(value)
|
|
4543
|
-
}
|
|
4544
|
-
async fn validate_proposal(State(state): State<AppState>, Extension(principal): Extension<Principal>, Path(id): Path<String>, Json(input): Json<ProposalActorRequest>) -> Result<Json<Value>, ApiError> {
|
|
4545
|
-
let tenant = application_scope(&state, &principal.key_id, &input.application_id, "application:write")?; let mut value = proposal_value(&state, &id)?;
|
|
4546
|
-
if !proposal_scope_matches(&value, &input.application_id, &input.environment) { return Err(ApiError(StatusCode::NOT_FOUND, "proposal not found".into())); }
|
|
4547
|
-
let revision_id = state.applications.pointers(&input.application_id).get(&input.environment).cloned().ok_or(ApiError(StatusCode::NOT_FOUND,"active contract not found".into()))?;
|
|
4548
|
-
let revision = state.applications.revision(&tenant,&input.application_id,&revision_id).ok_or(ApiError(StatusCode::NOT_FOUND,"application revision not found".into()))?;
|
|
4549
|
-
let hash = revision.manifest.metadata.labels.get("feltdb.contract_hash").unwrap_or(&revision.manifest_hash);
|
|
4550
|
-
if value.get("base_contract_hash").and_then(Value::as_str) != Some(hash) { return Err(ApiError(StatusCode::CONFLICT,"STALE_PROPOSAL".into())); }
|
|
4551
|
-
if let Some(manifest) = input.proposed_manifest {
|
|
4552
|
-
let report = validate_manifest(&manifest, &tenant, &input.application_id, Some(&revision.manifest));
|
|
4553
|
-
if !report.valid { return Err(ApiError::structured(StatusCode::UNPROCESSABLE_ENTITY, json!({"code":"INVALID_PROPOSAL","validation":report}))); }
|
|
4554
|
-
let proposed = ApplicationRevision { revision_id: format!("proposal://{id}"), application_id:input.application_id.clone(), tenant_id:tenant,
|
|
4555
|
-
revision_number:revision.revision_number + 1, parent_revision_id:Some(revision.revision_id.clone()), manifest_hash:String::new(),
|
|
4556
|
-
manifest, created_by:principal.key_id.clone(), created_at:unix_seconds_i64() as u64, status:RevisionStatus::Committed };
|
|
4557
|
-
value["contract_diff"] = serde_json::to_value(diff_manifests(Some(&revision), &proposed).changes).map_err(|e|ApiError(StatusCode::INTERNAL_SERVER_ERROR,e.to_string()))?;
|
|
4558
|
-
}
|
|
4559
|
-
value["source_plan"] = json!({"files":[{"path":"feltdb.flow","operation":"modify"},{"path":"src/feltdb/generated-contract.ts","operation":"modify"}]});
|
|
4560
|
-
Ok(Json(move_proposal(&state,&id,value,"validated",&principal.key_id)?))
|
|
4561
|
-
}
|
|
4562
|
-
async fn preview_proposal(State(state): State<AppState>, Extension(principal): Extension<Principal>, Path(id): Path<String>, Json(input): Json<ProposalActorRequest>) -> Result<Json<Value>, ApiError> {
|
|
4563
|
-
application_scope(&state,&principal.key_id,&input.application_id,"application:write")?; let value=proposal_value(&state,&id)?;
|
|
4564
|
-
if !proposal_scope_matches(&value,&input.application_id,&input.environment) { return Err(ApiError(StatusCode::NOT_FOUND,"proposal not found".into())); }
|
|
4565
|
-
Ok(Json(json!({"proposal":move_proposal(&state,&id,value,"previewed",&principal.key_id)?})))
|
|
4566
|
-
}
|
|
4567
|
-
async fn transition_proposal(State(state): State<AppState>, Extension(principal): Extension<Principal>, headers: HeaderMap, Path(id): Path<String>, Json(input): Json<ProposalStatusRequest>) -> Result<Json<Value>, ApiError> {
|
|
4568
|
-
application_scope(&state,&principal.key_id,&input.application_id,"application:write")?; let value=proposal_value(&state,&id)?;
|
|
4569
|
-
if !proposal_scope_matches(&value,&input.application_id,&input.environment) { return Err(ApiError(StatusCode::NOT_FOUND,"proposal not found".into())); }
|
|
4570
|
-
if !matches!(input.status.as_str(),"approved"|"rejected"|"expired"|"applied") { return Err(ApiError(StatusCode::BAD_REQUEST,"unsupported proposal status".into())); }
|
|
4571
|
-
if input.status == "applied" && headers.get("FeltDB-Repository-Apply").and_then(|v|v.to_str().ok()) != Some("1") { return Err(ApiError(StatusCode::FORBIDDEN,"REPOSITORY_AUTHORITY_REQUIRED".into())); }
|
|
4572
|
-
if input.status == "approved" && value.get("warnings").and_then(Value::as_array).map(|items|items.iter().any(|item|item.as_str().unwrap_or("").starts_with("Authorization "))).unwrap_or(false) && !input.approve_authorization { return Err(ApiError(StatusCode::FORBIDDEN,"AUTHORIZATION_APPROVAL_REQUIRED".into())); }
|
|
4573
|
-
Ok(Json(move_proposal(&state,&id,value,&input.status,&principal.key_id)?))
|
|
4574
|
-
}
|
|
4575
|
-
fn auth_response(identity: feltdb_server::identity::Identity, session: feltdb_server::identity::IdentitySession, token: String) -> Json<Value> {
|
|
5892
|
+
fn auth_response(
|
|
5893
|
+
identity: feltdb_server::identity::Identity,
|
|
5894
|
+
session: feltdb_server::identity::IdentitySession,
|
|
5895
|
+
token: String,
|
|
5896
|
+
) -> Json<Value> {
|
|
4576
5897
|
Json(json!({
|
|
4577
5898
|
"access_token": token,
|
|
4578
5899
|
"token_type": "Bearer",
|
|
@@ -4581,25 +5902,87 @@ fn auth_response(identity: feltdb_server::identity::Identity, session: feltdb_se
|
|
|
4581
5902
|
"session": { "session_id": session.session_id, "issued_at": session.issued_at, "expires_at": session.expires_at }
|
|
4582
5903
|
}))
|
|
4583
5904
|
}
|
|
4584
|
-
async fn auth_sign_up(
|
|
4585
|
-
|
|
4586
|
-
|
|
4587
|
-
|
|
4588
|
-
state
|
|
4589
|
-
|
|
5905
|
+
async fn auth_sign_up(
|
|
5906
|
+
State(state): State<AppState>,
|
|
5907
|
+
Json(input): Json<SignUpRequest>,
|
|
5908
|
+
) -> Result<(StatusCode, Json<Value>), ApiError> {
|
|
5909
|
+
if state
|
|
5910
|
+
.tenancy
|
|
5911
|
+
.application_tenant(&input.application_id)
|
|
5912
|
+
.is_none()
|
|
5913
|
+
{
|
|
5914
|
+
return Err(ApiError(
|
|
5915
|
+
StatusCode::NOT_FOUND,
|
|
5916
|
+
"application not found".into(),
|
|
5917
|
+
));
|
|
5918
|
+
}
|
|
5919
|
+
let (identity, session, token) = match state
|
|
5920
|
+
.identities
|
|
5921
|
+
.sign_up(&input.email, &input.password, input.display_name)
|
|
5922
|
+
{
|
|
5923
|
+
Ok(created) => created,
|
|
5924
|
+
Err(error) if error == "identity already exists" => state
|
|
5925
|
+
.identities
|
|
5926
|
+
.sign_in(&input.email, &input.password)
|
|
5927
|
+
.map_err(|_| ApiError(StatusCode::UNAUTHORIZED, "invalid credentials".into()))?,
|
|
5928
|
+
Err(error) => return Err(ApiError(StatusCode::BAD_REQUEST, error)),
|
|
5929
|
+
};
|
|
5930
|
+
state
|
|
5931
|
+
.tenancy
|
|
5932
|
+
.register_platform_user(
|
|
5933
|
+
&identity.subject_id,
|
|
5934
|
+
identity.email.as_deref().unwrap_or(&identity.identity_id),
|
|
5935
|
+
)
|
|
5936
|
+
.map_err(control_error)?;
|
|
5937
|
+
state
|
|
5938
|
+
.tenancy
|
|
5939
|
+
.register_application_actor(&identity.subject_id, &input.application_id)
|
|
5940
|
+
.map_err(control_error)?;
|
|
4590
5941
|
Ok((StatusCode::CREATED, auth_response(identity, session, token)))
|
|
4591
5942
|
}
|
|
4592
|
-
async fn auth_sign_in(
|
|
4593
|
-
|
|
5943
|
+
async fn auth_sign_in(
|
|
5944
|
+
State(state): State<AppState>,
|
|
5945
|
+
Json(input): Json<SignInRequest>,
|
|
5946
|
+
) -> Result<Json<Value>, ApiError> {
|
|
5947
|
+
let (identity, session, token) = state
|
|
5948
|
+
.identities
|
|
5949
|
+
.sign_in(&input.email, &input.password)
|
|
4594
5950
|
.map_err(|_| ApiError(StatusCode::UNAUTHORIZED, "invalid credentials".into()))?;
|
|
5951
|
+
if let Some(application_id) = input.application_id {
|
|
5952
|
+
if state.tenancy.application_tenant(&application_id).is_none() {
|
|
5953
|
+
return Err(ApiError(StatusCode::NOT_FOUND, "application not found".into()));
|
|
5954
|
+
}
|
|
5955
|
+
state
|
|
5956
|
+
.tenancy
|
|
5957
|
+
.register_platform_user(
|
|
5958
|
+
&identity.subject_id,
|
|
5959
|
+
identity.email.as_deref().unwrap_or(&identity.identity_id),
|
|
5960
|
+
)
|
|
5961
|
+
.map_err(control_error)?;
|
|
5962
|
+
state
|
|
5963
|
+
.tenancy
|
|
5964
|
+
.register_application_actor(&identity.subject_id, &application_id)
|
|
5965
|
+
.map_err(control_error)?;
|
|
5966
|
+
}
|
|
4595
5967
|
Ok(auth_response(identity, session, token))
|
|
4596
5968
|
}
|
|
4597
5969
|
async fn auth_session(Extension(principal): Extension<Principal>) -> Json<Value> {
|
|
4598
|
-
Json(
|
|
5970
|
+
Json(
|
|
5971
|
+
json!({ "actor": { "user_id": principal.key_id, "identity_id": principal.identity_id }, "session_id": principal.session_id }),
|
|
5972
|
+
)
|
|
4599
5973
|
}
|
|
4600
|
-
async fn auth_sign_out(
|
|
4601
|
-
|
|
4602
|
-
|
|
5974
|
+
async fn auth_sign_out(
|
|
5975
|
+
State(state): State<AppState>,
|
|
5976
|
+
Extension(principal): Extension<Principal>,
|
|
5977
|
+
) -> Result<StatusCode, ApiError> {
|
|
5978
|
+
let session = principal.session_id.ok_or(ApiError(
|
|
5979
|
+
StatusCode::BAD_REQUEST,
|
|
5980
|
+
"request is not an actor session".into(),
|
|
5981
|
+
))?;
|
|
5982
|
+
state
|
|
5983
|
+
.identities
|
|
5984
|
+
.revoke_session(&principal.key_id, &session, "user_logout")
|
|
5985
|
+
.map_err(control_error)?;
|
|
4603
5986
|
Ok(StatusCode::NO_CONTENT)
|
|
4604
5987
|
}
|
|
4605
5988
|
async fn application_openapi() -> Json<Value> {
|
|
@@ -4624,6 +6007,13 @@ async fn application_openapi() -> Json<Value> {
|
|
|
4624
6007
|
"/query": { "post": { "responses": { "200": { "description": "Bounded authorized query" } } } },
|
|
4625
6008
|
"/transactions": { "post": { "responses": { "200": { "description": "Atomic idempotent mutation" } } } },
|
|
4626
6009
|
"/events": { "get": { "responses": { "200": { "description": "Application event stream" } } } },
|
|
6010
|
+
"/intents": {
|
|
6011
|
+
"get": { "responses": { "200": { "description": "Durable application Intents" } } },
|
|
6012
|
+
"post": { "parameters": [{ "name":"Idempotency-Key", "in":"header", "required":false, "schema":{"type":"string"} }], "responses": { "200": { "description": "Canonical Intent execution result" } } }
|
|
6013
|
+
},
|
|
6014
|
+
"/intents/{id}": { "get": { "responses": { "200": { "description": "Canonical Intent" } } } },
|
|
6015
|
+
"/intents/{id}/{artifact}": { "get": { "responses": { "200": { "description": "Intent understanding, plan, Proposal, or history" } } } },
|
|
6016
|
+
"/proposals": { "get": { "responses": { "200": { "description": "Durable Proposals" } } }, "post": { "responses": { "201": { "description": "Created Proposal" } } } },
|
|
4627
6017
|
"/workflows/{name}/run": { "post": { "responses": { "201": { "description": "Workflow execution" } } } },
|
|
4628
6018
|
"/agents/{name}/run": { "post": { "responses": { "201": { "description": "Agent execution" } } } },
|
|
4629
6019
|
"/capabilities/{name}/run": { "post": { "responses": { "200": { "description": "Public capability result" } } } }
|
|
@@ -5029,8 +6419,29 @@ async fn list_runtime_triggers(
|
|
|
5029
6419
|
Ok(Json(json!(contract.triggers)))
|
|
5030
6420
|
}
|
|
5031
6421
|
|
|
5032
|
-
fn state_policy_resource_matches(resource
|
|
5033
|
-
|
|
6422
|
+
fn state_policy_resource_matches(resource: &str, collection: &str) -> bool {
|
|
6423
|
+
resource == collection
|
|
6424
|
+
|| resource
|
|
6425
|
+
.strip_suffix('*')
|
|
6426
|
+
.is_some_and(|prefix| !prefix.contains('*') && collection.starts_with(prefix))
|
|
6427
|
+
}
|
|
6428
|
+
fn matching_state_policy<'a>(
|
|
6429
|
+
contract: &'a ApplicationRuntimeContract,
|
|
6430
|
+
collection: &str,
|
|
6431
|
+
) -> Option<&'a feltdb::application::PolicyDefinition> {
|
|
6432
|
+
contract
|
|
6433
|
+
.policies
|
|
6434
|
+
.definitions
|
|
6435
|
+
.iter()
|
|
6436
|
+
.find(|p| p.resource == collection)
|
|
6437
|
+
.or_else(|| {
|
|
6438
|
+
contract
|
|
6439
|
+
.policies
|
|
6440
|
+
.definitions
|
|
6441
|
+
.iter()
|
|
6442
|
+
.find(|p| state_policy_resource_matches(&p.resource, collection))
|
|
6443
|
+
})
|
|
6444
|
+
}
|
|
5034
6445
|
fn authorize_transaction_collections(
|
|
5035
6446
|
principal: &Principal,
|
|
5036
6447
|
contract: &ApplicationRuntimeContract,
|
|
@@ -5063,14 +6474,14 @@ fn state_authorization(
|
|
|
5063
6474
|
) -> AuthorizationContext {
|
|
5064
6475
|
let subject = format!("{}:{}", principal.subject_type, principal.key_id);
|
|
5065
6476
|
|
|
5066
|
-
let policy=matching_state_policy(contract,collection);
|
|
6477
|
+
let policy = matching_state_policy(contract, collection);
|
|
5067
6478
|
let policy_subject = policy.and_then(|p| {
|
|
5068
|
-
|
|
5069
|
-
|
|
5070
|
-
|
|
5071
|
-
|
|
5072
|
-
|
|
5073
|
-
|
|
6479
|
+
if operation == "read" {
|
|
6480
|
+
p.read.as_ref().and_then(|s| PolicySubject::from_str(s))
|
|
6481
|
+
} else {
|
|
6482
|
+
p.write.as_ref().and_then(|s| PolicySubject::from_str(s))
|
|
6483
|
+
}
|
|
6484
|
+
});
|
|
5074
6485
|
|
|
5075
6486
|
// Evaluate policy if found
|
|
5076
6487
|
let capabilities = if let Some(policy) = policy_subject {
|
|
@@ -5098,7 +6509,16 @@ fn state_authorization(
|
|
|
5098
6509
|
BTreeSet::new()
|
|
5099
6510
|
}
|
|
5100
6511
|
}
|
|
5101
|
-
} else if policy.is_some_and(|p|
|
|
6512
|
+
} else if policy.is_some_and(|p| {
|
|
6513
|
+
p.capabilities.iter().any(|capability| {
|
|
6514
|
+
capability
|
|
6515
|
+
== if operation == "read" {
|
|
6516
|
+
"state:read"
|
|
6517
|
+
} else {
|
|
6518
|
+
"state:write"
|
|
6519
|
+
}
|
|
6520
|
+
})
|
|
6521
|
+
}) {
|
|
5102
6522
|
let capability = if operation == "read" {
|
|
5103
6523
|
"state:read"
|
|
5104
6524
|
} else {
|
|
@@ -5434,16 +6854,25 @@ async fn execute_canonical_transaction(
|
|
|
5434
6854
|
input.transaction.revision_id = contract.revision_id.clone();
|
|
5435
6855
|
input.transaction.schema_version = contract.state.schema.schema_version;
|
|
5436
6856
|
input.transaction.state_namespace = Some(contract.environment.state_namespace.clone());
|
|
5437
|
-
let collections = input
|
|
6857
|
+
let collections = input
|
|
6858
|
+
.transaction
|
|
6859
|
+
.operations
|
|
6860
|
+
.iter()
|
|
5438
6861
|
.map(|operation| operation.collection.clone())
|
|
5439
6862
|
.collect::<Vec<_>>();
|
|
5440
|
-
let (authorized, write_policies) =
|
|
6863
|
+
let (authorized, write_policies) =
|
|
6864
|
+
authorize_transaction_collections(&principal, &contract, &tenant, &collections);
|
|
6865
|
+
input.transaction.authorization = state_authorization_legacy(
|
|
5441
6866
|
&principal,
|
|
5442
|
-
&contract,
|
|
5443
6867
|
&tenant,
|
|
5444
|
-
&
|
|
6868
|
+
&input.application_id,
|
|
6869
|
+
&contract.revision_id,
|
|
6870
|
+
if authorized {
|
|
6871
|
+
"state:write"
|
|
6872
|
+
} else {
|
|
6873
|
+
"state:denied"
|
|
6874
|
+
},
|
|
5445
6875
|
);
|
|
5446
|
-
input.transaction.authorization=state_authorization_legacy(&principal,&tenant,&input.application_id,&contract.revision_id,if authorized{"state:write"}else{"state:denied"});
|
|
5447
6876
|
let result = execute_transaction_with_collection_policies(
|
|
5448
6877
|
&state.db,
|
|
5449
6878
|
&contract.state.schema,
|
|
@@ -6257,18 +7686,54 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
|
|
6257
7686
|
)
|
|
6258
7687
|
.route("/v1/schema", get(get_state_schema))
|
|
6259
7688
|
.route("/v1/application", get(discover_application))
|
|
7689
|
+
.route("/v1/application/schema", get(inspect_application_schema))
|
|
7690
|
+
.route("/v1/data/{collection}", get(inspect_collection))
|
|
7691
|
+
.route("/v1/data/{collection}/{id}", get(inspect_record))
|
|
7692
|
+
.route("/v1/intents", get(list_intents).post(execute_intent))
|
|
7693
|
+
.route("/v1/intents/{id}", get(get_intent_root))
|
|
7694
|
+
.route("/v1/intents/{id}/{operation}", get(get_intent))
|
|
6260
7695
|
.route("/v1/proposals", get(list_proposals).post(create_proposal))
|
|
6261
7696
|
.route("/v1/proposals/{id}", get(get_proposal))
|
|
6262
7697
|
.route("/v1/proposals/{id}/history", get(proposal_history))
|
|
6263
|
-
.route(
|
|
6264
|
-
|
|
6265
|
-
|
|
7698
|
+
.route(
|
|
7699
|
+
"/v1/proposals/{id}/validate",
|
|
7700
|
+
axum::routing::post(validate_proposal),
|
|
7701
|
+
)
|
|
7702
|
+
.route(
|
|
7703
|
+
"/v1/proposals/{id}/preview",
|
|
7704
|
+
axum::routing::post(preview_proposal),
|
|
7705
|
+
)
|
|
7706
|
+
.route(
|
|
7707
|
+
"/v1/proposals/{id}/approve",
|
|
7708
|
+
axum::routing::post(approve_proposal),
|
|
7709
|
+
)
|
|
7710
|
+
.route(
|
|
7711
|
+
"/v1/proposals/{id}/reject",
|
|
7712
|
+
axum::routing::post(reject_proposal),
|
|
7713
|
+
)
|
|
7714
|
+
.route(
|
|
7715
|
+
"/v1/proposals/{id}/status",
|
|
7716
|
+
get(proposal_status).patch(transition_proposal),
|
|
7717
|
+
)
|
|
7718
|
+
.route("/v1/proposals/{id}/convergence", get(proposal_convergence).post(record_proposal_convergence))
|
|
7719
|
+
.route("/v1/previews/{id}", get(get_proposal_preview))
|
|
7720
|
+
.route("/v1/previews/{id}/data/{collection}", get(inspect_proposal_preview_data))
|
|
7721
|
+
.route("/v1/previews/{id}/simulate", axum::routing::post(simulate_proposal_preview))
|
|
6266
7722
|
.route("/v1/auth/session", get(auth_session))
|
|
6267
7723
|
.route("/v1/auth/signout", axum::routing::post(auth_sign_out))
|
|
6268
7724
|
.route("/v1/events", get(events))
|
|
6269
|
-
.route(
|
|
6270
|
-
|
|
6271
|
-
|
|
7725
|
+
.route(
|
|
7726
|
+
"/v1/workflows/{name}/run",
|
|
7727
|
+
axum::routing::post(start_workflow_run),
|
|
7728
|
+
)
|
|
7729
|
+
.route(
|
|
7730
|
+
"/v1/agents/{name}/run",
|
|
7731
|
+
axum::routing::post(start_agent_run),
|
|
7732
|
+
)
|
|
7733
|
+
.route(
|
|
7734
|
+
"/v1/capabilities/{name}/run",
|
|
7735
|
+
axum::routing::post(execute_application_capability),
|
|
7736
|
+
)
|
|
6272
7737
|
.route("/v1/state/version", get(get_state_version))
|
|
6273
7738
|
.route("/v1/query", axum::routing::post(execute_canonical_query))
|
|
6274
7739
|
.route(
|
|
@@ -6637,6 +8102,10 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
|
|
6637
8102
|
.route("/v1/openapi.json", get(application_openapi))
|
|
6638
8103
|
.route("/v1/auth/signup", axum::routing::post(auth_sign_up))
|
|
6639
8104
|
.route("/v1/auth/signin", axum::routing::post(auth_sign_in))
|
|
8105
|
+
.route("/internal/intents/by-hash", get(internal_find_intent))
|
|
8106
|
+
.route("/internal/intents", axum::routing::post(internal_create_intent))
|
|
8107
|
+
.route("/internal/intents/{id}", put(internal_replace_intent))
|
|
8108
|
+
.route("/internal/intent-events", axum::routing::post(internal_create_intent_event))
|
|
6640
8109
|
.route("/ready", get(readiness))
|
|
6641
8110
|
.route("/runtime", get(runtime))
|
|
6642
8111
|
.route("/metrics", get(network_metrics))
|
|
@@ -6733,7 +8202,16 @@ async fn authenticate(
|
|
|
6733
8202
|
(Some(principal), _, _) => principal,
|
|
6734
8203
|
(None, Some(session), _) => Principal {
|
|
6735
8204
|
key_id: session.subject_id,
|
|
6736
|
-
scopes: vec![
|
|
8205
|
+
scopes: vec![
|
|
8206
|
+
"application:read".into(),
|
|
8207
|
+
"application:revision:read".into(),
|
|
8208
|
+
"state:read".into(),
|
|
8209
|
+
"state:write".into(),
|
|
8210
|
+
"events:read".into(),
|
|
8211
|
+
"workflows:run".into(),
|
|
8212
|
+
"agents:run".into(),
|
|
8213
|
+
"capabilities:run".into(),
|
|
8214
|
+
],
|
|
6737
8215
|
subject_type: "human".into(),
|
|
6738
8216
|
identity_id: Some(session.identity_id),
|
|
6739
8217
|
session_id: Some(session.session_id),
|
|
@@ -6964,9 +8442,7 @@ async fn health(State(state): State<AppState>) -> Json<HealthResponse<'static>>
|
|
|
6964
8442
|
})
|
|
6965
8443
|
}
|
|
6966
8444
|
|
|
6967
|
-
async fn managed_diagnostics(
|
|
6968
|
-
State(state): State<AppState>,
|
|
6969
|
-
) -> Json<ManagedDiagnosticsResponse> {
|
|
8445
|
+
async fn managed_diagnostics(State(state): State<AppState>) -> Json<ManagedDiagnosticsResponse> {
|
|
6970
8446
|
let recovery = match state.cluster.proposal().map(|value| value.phase) {
|
|
6971
8447
|
Some(ProposalPhase::Preparing | ProposalPhase::Prepared) => "recovering",
|
|
6972
8448
|
Some(ProposalPhase::Aborted) => "attention-required",
|
|
@@ -6983,8 +8459,7 @@ async fn managed_diagnostics(
|
|
|
6983
8459
|
storage_format: 1,
|
|
6984
8460
|
promoted_revision: std::env::var("FELTDB_PROMOTED_REVISION")
|
|
6985
8461
|
.unwrap_or_else(|_| "unknown".into()),
|
|
6986
|
-
environment: std::env::var("FELTDB_ENVIRONMENT")
|
|
6987
|
-
.unwrap_or_else(|_| "development".into()),
|
|
8462
|
+
environment: std::env::var("FELTDB_ENVIRONMENT").unwrap_or_else(|_| "development".into()),
|
|
6988
8463
|
recovery: recovery.into(),
|
|
6989
8464
|
uptime: format!("{uptime_seconds}s"),
|
|
6990
8465
|
})
|
|
@@ -8733,7 +10208,12 @@ async fn execute_capability(
|
|
|
8733
10208
|
Path(name): Path<String>,
|
|
8734
10209
|
Json(input): Json<Value>,
|
|
8735
10210
|
) -> Result<Json<Value>, ApiError> {
|
|
8736
|
-
if !principal.permits("capabilities:run") {
|
|
10211
|
+
if !principal.permits("capabilities:run") {
|
|
10212
|
+
return Err(ApiError(
|
|
10213
|
+
StatusCode::FORBIDDEN,
|
|
10214
|
+
"capability execution is not permitted".into(),
|
|
10215
|
+
));
|
|
10216
|
+
}
|
|
8737
10217
|
validate_segment(&name)?;
|
|
8738
10218
|
Ok(Json(execute_named_capability(&state, &name, &input)?))
|
|
8739
10219
|
}
|
|
@@ -8745,15 +10225,42 @@ async fn execute_application_capability(
|
|
|
8745
10225
|
Query(input): Query<ApplicationDiscoveryRequest>,
|
|
8746
10226
|
Json(payload): Json<Value>,
|
|
8747
10227
|
) -> Result<Json<Value>, ApiError> {
|
|
8748
|
-
let tenant = application_scope(
|
|
8749
|
-
|
|
8750
|
-
.
|
|
8751
|
-
|
|
8752
|
-
|
|
8753
|
-
|
|
8754
|
-
|
|
10228
|
+
let tenant = application_scope(
|
|
10229
|
+
&state,
|
|
10230
|
+
&principal.key_id,
|
|
10231
|
+
&input.application_id,
|
|
10232
|
+
"capabilities:run",
|
|
10233
|
+
)?;
|
|
10234
|
+
let revision_id = state
|
|
10235
|
+
.applications
|
|
10236
|
+
.pointers(&input.application_id)
|
|
10237
|
+
.get(&input.environment)
|
|
10238
|
+
.cloned()
|
|
10239
|
+
.ok_or(ApiError(
|
|
10240
|
+
StatusCode::NOT_FOUND,
|
|
10241
|
+
"application environment has no active contract".into(),
|
|
10242
|
+
))?;
|
|
10243
|
+
let revision = state
|
|
10244
|
+
.applications
|
|
10245
|
+
.revision(&tenant, &input.application_id, &revision_id)
|
|
10246
|
+
.ok_or(ApiError(
|
|
10247
|
+
StatusCode::NOT_FOUND,
|
|
10248
|
+
"application revision not found".into(),
|
|
10249
|
+
))?;
|
|
10250
|
+
let capability = revision
|
|
10251
|
+
.manifest
|
|
10252
|
+
.capabilities
|
|
10253
|
+
.iter()
|
|
10254
|
+
.find(|value| value.name == name)
|
|
10255
|
+
.ok_or(ApiError(
|
|
10256
|
+
StatusCode::NOT_FOUND,
|
|
10257
|
+
"capability not found".into(),
|
|
10258
|
+
))?;
|
|
8755
10259
|
if capability.visibility != "public" {
|
|
8756
|
-
return Err(ApiError(
|
|
10260
|
+
return Err(ApiError(
|
|
10261
|
+
StatusCode::FORBIDDEN,
|
|
10262
|
+
"capability is not externally invocable".into(),
|
|
10263
|
+
));
|
|
8757
10264
|
}
|
|
8758
10265
|
Ok(Json(execute_named_capability(&state, &name, &payload)?))
|
|
8759
10266
|
}
|
|
@@ -8947,7 +10454,12 @@ async fn start_workflow_run(
|
|
|
8947
10454
|
Path(name): Path<String>,
|
|
8948
10455
|
Json(request): Json<StartRun>,
|
|
8949
10456
|
) -> Result<(StatusCode, Json<RecordResponse>), ApiError> {
|
|
8950
|
-
if !principal.permits("workflows:run") {
|
|
10457
|
+
if !principal.permits("workflows:run") {
|
|
10458
|
+
return Err(ApiError(
|
|
10459
|
+
StatusCode::FORBIDDEN,
|
|
10460
|
+
"workflow execution is not permitted".into(),
|
|
10461
|
+
));
|
|
10462
|
+
}
|
|
8951
10463
|
validate_segment(&name)?;
|
|
8952
10464
|
let definition = required_value(&state, "_flow_workflows", &name, "workflow not found")?;
|
|
8953
10465
|
let steps = definition["steps"].as_array().ok_or_else(|| {
|
|
@@ -9109,7 +10621,12 @@ async fn start_agent_run(
|
|
|
9109
10621
|
Path(name): Path<String>,
|
|
9110
10622
|
Json(request): Json<StartAgentRun>,
|
|
9111
10623
|
) -> Result<(StatusCode, Json<RecordResponse>), ApiError> {
|
|
9112
|
-
if !principal.permits("agents:run") {
|
|
10624
|
+
if !principal.permits("agents:run") {
|
|
10625
|
+
return Err(ApiError(
|
|
10626
|
+
StatusCode::FORBIDDEN,
|
|
10627
|
+
"agent execution is not permitted".into(),
|
|
10628
|
+
));
|
|
10629
|
+
}
|
|
9113
10630
|
validate_segment(&name)?;
|
|
9114
10631
|
required_value(&state, "_flow_agents", &name, "agent not found")?;
|
|
9115
10632
|
if request.goal.trim().is_empty() {
|
|
@@ -9331,10 +10848,13 @@ struct BoundedQueryPage {
|
|
|
9331
10848
|
}
|
|
9332
10849
|
|
|
9333
10850
|
fn bounded_query_error(code: &str, message: impl Into<String>) -> ApiError {
|
|
9334
|
-
ApiError::structured(
|
|
9335
|
-
|
|
9336
|
-
|
|
9337
|
-
|
|
10851
|
+
ApiError::structured(
|
|
10852
|
+
StatusCode::UNPROCESSABLE_ENTITY,
|
|
10853
|
+
json!({
|
|
10854
|
+
"code": code,
|
|
10855
|
+
"message": message.into(),
|
|
10856
|
+
}),
|
|
10857
|
+
)
|
|
9338
10858
|
}
|
|
9339
10859
|
|
|
9340
10860
|
fn bounded_query_hash(request: &BoundedQueryRequest) -> Result<String, ApiError> {
|
|
@@ -9357,7 +10877,10 @@ fn query_scalar_cmp(left: Option<&Value>, right: Option<&Value>) -> std::cmp::Or
|
|
|
9357
10877
|
(Some(Value::Null), Some(Value::Null)) => std::cmp::Ordering::Equal,
|
|
9358
10878
|
(Some(Value::Null), Some(_)) => std::cmp::Ordering::Less,
|
|
9359
10879
|
(Some(_), Some(Value::Null)) => std::cmp::Ordering::Greater,
|
|
9360
|
-
(Some(Value::Number(a)), Some(Value::Number(b))) => a
|
|
10880
|
+
(Some(Value::Number(a)), Some(Value::Number(b))) => a
|
|
10881
|
+
.as_f64()
|
|
10882
|
+
.partial_cmp(&b.as_f64())
|
|
10883
|
+
.unwrap_or(std::cmp::Ordering::Equal),
|
|
9361
10884
|
(Some(Value::String(a)), Some(Value::String(b))) => a.cmp(b),
|
|
9362
10885
|
(Some(Value::Bool(a)), Some(Value::Bool(b))) => a.cmp(b),
|
|
9363
10886
|
(Some(a), Some(b)) => a.to_string().cmp(&b.to_string()),
|
|
@@ -9366,31 +10889,60 @@ fn query_scalar_cmp(left: Option<&Value>, right: Option<&Value>) -> std::cmp::Or
|
|
|
9366
10889
|
|
|
9367
10890
|
fn condition_matches(record: &Value, condition: &BoundedQueryCondition) -> Result<bool, ApiError> {
|
|
9368
10891
|
if condition.operators.len() != 1 || condition.field.trim().is_empty() {
|
|
9369
|
-
return Err(bounded_query_error(
|
|
10892
|
+
return Err(bounded_query_error(
|
|
10893
|
+
"INVALID_QUERY",
|
|
10894
|
+
"each where condition requires a field and exactly one operator",
|
|
10895
|
+
));
|
|
9370
10896
|
}
|
|
9371
|
-
let (operator, expected) = condition
|
|
10897
|
+
let (operator, expected) = condition
|
|
10898
|
+
.operators
|
|
10899
|
+
.iter()
|
|
10900
|
+
.next()
|
|
10901
|
+
.expect("validated operator");
|
|
9372
10902
|
let actual = record.get(&condition.field);
|
|
9373
|
-
if operator == "eq" {
|
|
9374
|
-
|
|
9375
|
-
|
|
10903
|
+
if operator == "eq" {
|
|
10904
|
+
return Ok(actual == Some(expected));
|
|
10905
|
+
}
|
|
10906
|
+
if operator == "neq" {
|
|
10907
|
+
return Ok(actual != Some(expected));
|
|
10908
|
+
}
|
|
10909
|
+
let Some(actual) = actual else {
|
|
10910
|
+
return Ok(false);
|
|
10911
|
+
};
|
|
9376
10912
|
let comparison = query_scalar_cmp(Some(actual), Some(expected));
|
|
9377
10913
|
Ok(match operator.as_str() {
|
|
9378
10914
|
"lt" => comparison.is_lt(),
|
|
9379
10915
|
"lte" => !comparison.is_gt(),
|
|
9380
10916
|
"gt" => comparison.is_gt(),
|
|
9381
10917
|
"gte" => !comparison.is_lt(),
|
|
9382
|
-
_ =>
|
|
10918
|
+
_ => {
|
|
10919
|
+
return Err(bounded_query_error(
|
|
10920
|
+
"INVALID_QUERY",
|
|
10921
|
+
format!("unsupported where operator: {operator}"),
|
|
10922
|
+
))
|
|
10923
|
+
}
|
|
9383
10924
|
})
|
|
9384
10925
|
}
|
|
9385
10926
|
|
|
9386
|
-
fn issue_bounded_cursor(
|
|
10927
|
+
fn issue_bounded_cursor(
|
|
10928
|
+
state: &AppState,
|
|
10929
|
+
mut cursor: BoundedQueryCursor,
|
|
10930
|
+
) -> Result<String, ApiError> {
|
|
9387
10931
|
let token = uuid::Uuid::new_v4().simple().to_string();
|
|
9388
10932
|
let now = unix_seconds_i64().max(0) as u64;
|
|
9389
10933
|
cursor.created_at = now;
|
|
9390
|
-
let mut cursors = state.bounded_query_cursors.lock().map_err(|_|
|
|
10934
|
+
let mut cursors = state.bounded_query_cursors.lock().map_err(|_| {
|
|
10935
|
+
ApiError(
|
|
10936
|
+
StatusCode::INTERNAL_SERVER_ERROR,
|
|
10937
|
+
"query cursor store unavailable".into(),
|
|
10938
|
+
)
|
|
10939
|
+
})?;
|
|
9391
10940
|
cursors.retain(|_, value| now.saturating_sub(value.created_at) <= BOUNDED_CURSOR_TTL_SECONDS);
|
|
9392
10941
|
if cursors.len() >= 1024 {
|
|
9393
|
-
return Err(ApiError(
|
|
10942
|
+
return Err(ApiError(
|
|
10943
|
+
StatusCode::SERVICE_UNAVAILABLE,
|
|
10944
|
+
"query cursor capacity exhausted".into(),
|
|
10945
|
+
));
|
|
9394
10946
|
}
|
|
9395
10947
|
cursors.insert(token.clone(), cursor);
|
|
9396
10948
|
Ok(token)
|
|
@@ -9403,32 +10955,67 @@ async fn execute_bounded_query(
|
|
|
9403
10955
|
) -> Result<Json<BoundedQueryPage>, ApiError> {
|
|
9404
10956
|
validate_segment(&request.collection)?;
|
|
9405
10957
|
if request.limit == 0 || request.limit > MAX_BOUNDED_QUERY_LIMIT {
|
|
9406
|
-
return Err(bounded_query_error(
|
|
10958
|
+
return Err(bounded_query_error(
|
|
10959
|
+
"INVALID_QUERY",
|
|
10960
|
+
format!("limit must be between 1 and {MAX_BOUNDED_QUERY_LIMIT}"),
|
|
10961
|
+
));
|
|
9407
10962
|
}
|
|
9408
10963
|
if request.order_by.is_empty() {
|
|
9409
|
-
return Err(bounded_query_error(
|
|
10964
|
+
return Err(bounded_query_error(
|
|
10965
|
+
"INVALID_QUERY",
|
|
10966
|
+
"orderBy must contain at least one field",
|
|
10967
|
+
));
|
|
9410
10968
|
}
|
|
9411
10969
|
for order in &request.order_by {
|
|
9412
10970
|
if order.field.trim().is_empty() || !matches!(order.direction.as_str(), "asc" | "desc") {
|
|
9413
|
-
return Err(bounded_query_error(
|
|
10971
|
+
return Err(bounded_query_error(
|
|
10972
|
+
"INVALID_QUERY",
|
|
10973
|
+
"orderBy directions must be asc or desc",
|
|
10974
|
+
));
|
|
9414
10975
|
}
|
|
9415
10976
|
}
|
|
9416
10977
|
for condition in &request.conditions {
|
|
9417
|
-
if condition.operators.len() != 1
|
|
9418
|
-
||
|
|
9419
|
-
|
|
10978
|
+
if condition.operators.len() != 1
|
|
10979
|
+
|| condition.field.trim().is_empty()
|
|
10980
|
+
|| !condition.operators.keys().all(|operator| {
|
|
10981
|
+
matches!(
|
|
10982
|
+
operator.as_str(),
|
|
10983
|
+
"eq" | "neq" | "lt" | "lte" | "gt" | "gte"
|
|
10984
|
+
)
|
|
10985
|
+
})
|
|
10986
|
+
{
|
|
10987
|
+
return Err(bounded_query_error(
|
|
10988
|
+
"INVALID_QUERY",
|
|
10989
|
+
"each where condition requires a field and exactly one operator",
|
|
10990
|
+
));
|
|
9420
10991
|
}
|
|
9421
10992
|
}
|
|
9422
10993
|
let hash = bounded_query_hash(&request)?;
|
|
9423
10994
|
let (records, position) = if let Some(token) = &request.cursor {
|
|
9424
|
-
let cursor = state
|
|
9425
|
-
.
|
|
9426
|
-
.
|
|
10995
|
+
let cursor = state
|
|
10996
|
+
.bounded_query_cursors
|
|
10997
|
+
.lock()
|
|
10998
|
+
.map_err(|_| {
|
|
10999
|
+
ApiError(
|
|
11000
|
+
StatusCode::INTERNAL_SERVER_ERROR,
|
|
11001
|
+
"query cursor store unavailable".into(),
|
|
11002
|
+
)
|
|
11003
|
+
})?
|
|
11004
|
+
.get(token)
|
|
11005
|
+
.cloned()
|
|
9427
11006
|
.ok_or_else(|| bounded_query_error("INVALID_CURSOR", "cursor is invalid or expired"))?;
|
|
9428
|
-
if cursor.query_hash != hash
|
|
9429
|
-
|
|
11007
|
+
if cursor.query_hash != hash
|
|
11008
|
+
|| cursor.principal_key_id != principal.key_id
|
|
11009
|
+
|| cursor.namespace != state.namespace.as_ref()
|
|
11010
|
+
{
|
|
11011
|
+
return Err(bounded_query_error(
|
|
11012
|
+
"INVALID_CURSOR",
|
|
11013
|
+
"cursor does not match this query, principal, or namespace",
|
|
11014
|
+
));
|
|
9430
11015
|
}
|
|
9431
|
-
if (unix_seconds_i64().max(0) as u64).saturating_sub(cursor.created_at)
|
|
11016
|
+
if (unix_seconds_i64().max(0) as u64).saturating_sub(cursor.created_at)
|
|
11017
|
+
> BOUNDED_CURSOR_TTL_SECONDS
|
|
11018
|
+
{
|
|
9432
11019
|
return Err(bounded_query_error("INVALID_CURSOR", "cursor is expired"));
|
|
9433
11020
|
}
|
|
9434
11021
|
(cursor.records, cursor.position)
|
|
@@ -9437,13 +11024,21 @@ async fn execute_bounded_query(
|
|
|
9437
11024
|
for row in state.db.list_collection(&request.collection)? {
|
|
9438
11025
|
let mut value = row.value;
|
|
9439
11026
|
if let Some(object) = value.as_object_mut() {
|
|
9440
|
-
let id = row
|
|
11027
|
+
let id = row
|
|
11028
|
+
.key
|
|
11029
|
+
.split_once(':')
|
|
11030
|
+
.map(|(_, id)| id)
|
|
11031
|
+
.unwrap_or(&row.key);
|
|
9441
11032
|
// `recordId` is authority metadata for this query surface, not
|
|
9442
11033
|
// caller-controlled document data. It is the final total-order
|
|
9443
11034
|
// tie-breaker even when a document contains a field by that name.
|
|
9444
11035
|
object.insert("recordId".into(), Value::String(id.to_string()));
|
|
9445
11036
|
}
|
|
9446
|
-
if request
|
|
11037
|
+
if request
|
|
11038
|
+
.conditions
|
|
11039
|
+
.iter()
|
|
11040
|
+
.all(|condition| condition_matches(&value, condition).unwrap_or(false))
|
|
11041
|
+
{
|
|
9447
11042
|
records.push(value);
|
|
9448
11043
|
}
|
|
9449
11044
|
}
|
|
@@ -9452,7 +11047,13 @@ async fn execute_bounded_query(
|
|
|
9452
11047
|
records.sort_by(|left, right| {
|
|
9453
11048
|
for order in &request.order_by {
|
|
9454
11049
|
let comparison = query_scalar_cmp(left.get(&order.field), right.get(&order.field));
|
|
9455
|
-
if !comparison.is_eq() {
|
|
11050
|
+
if !comparison.is_eq() {
|
|
11051
|
+
return if order.direction == "desc" {
|
|
11052
|
+
comparison.reverse()
|
|
11053
|
+
} else {
|
|
11054
|
+
comparison
|
|
11055
|
+
};
|
|
11056
|
+
}
|
|
9456
11057
|
}
|
|
9457
11058
|
query_scalar_cmp(left.get("recordId"), right.get("recordId"))
|
|
9458
11059
|
});
|
|
@@ -9461,23 +11062,37 @@ async fn execute_bounded_query(
|
|
|
9461
11062
|
let end = position.saturating_add(request.limit).min(records.len());
|
|
9462
11063
|
let page = records[position.min(records.len())..end].to_vec();
|
|
9463
11064
|
let next_cursor = if end < records.len() {
|
|
9464
|
-
Some(issue_bounded_cursor(
|
|
9465
|
-
|
|
9466
|
-
|
|
9467
|
-
|
|
9468
|
-
|
|
9469
|
-
|
|
9470
|
-
|
|
9471
|
-
|
|
9472
|
-
|
|
9473
|
-
|
|
11065
|
+
Some(issue_bounded_cursor(
|
|
11066
|
+
&state,
|
|
11067
|
+
BoundedQueryCursor {
|
|
11068
|
+
query_hash: hash,
|
|
11069
|
+
principal_key_id: principal.key_id,
|
|
11070
|
+
namespace: state.namespace.to_string(),
|
|
11071
|
+
records: records.clone(),
|
|
11072
|
+
position: end,
|
|
11073
|
+
created_at: 0,
|
|
11074
|
+
},
|
|
11075
|
+
)?)
|
|
11076
|
+
} else {
|
|
11077
|
+
None
|
|
11078
|
+
};
|
|
11079
|
+
Ok(Json(BoundedQueryPage {
|
|
11080
|
+
records: page,
|
|
11081
|
+
exhausted: next_cursor.is_none(),
|
|
11082
|
+
next_cursor,
|
|
11083
|
+
}))
|
|
9474
11084
|
}
|
|
9475
11085
|
|
|
9476
11086
|
async fn list_records(
|
|
9477
11087
|
State(state): State<AppState>,
|
|
9478
11088
|
Path(collection): Path<String>,
|
|
9479
11089
|
) -> Result<Json<Vec<RecordResponse>>, ApiError> {
|
|
9480
|
-
if collection == PROPOSAL_COLLECTION || collection == PROPOSAL_EVENT_COLLECTION
|
|
11090
|
+
if collection == PROPOSAL_COLLECTION || collection == PROPOSAL_EVENT_COLLECTION || collection == CONVERGENCE_COLLECTION || collection == INTENT_COLLECTION || collection == INTENT_EVENT_COLLECTION {
|
|
11091
|
+
return Err(ApiError(
|
|
11092
|
+
StatusCode::FORBIDDEN,
|
|
11093
|
+
"system collection requires proposal service".into(),
|
|
11094
|
+
));
|
|
11095
|
+
}
|
|
9481
11096
|
validate_segment(&collection)?;
|
|
9482
11097
|
let rows = state.db.list_collection(&collection)?;
|
|
9483
11098
|
Ok(Json(rows.into_iter().map(record_response).collect()))
|
|
@@ -9487,7 +11102,12 @@ async fn get_record(
|
|
|
9487
11102
|
State(state): State<AppState>,
|
|
9488
11103
|
Path((collection, id)): Path<(String, String)>,
|
|
9489
11104
|
) -> Result<Json<RecordResponse>, ApiError> {
|
|
9490
|
-
if collection == PROPOSAL_COLLECTION || collection == PROPOSAL_EVENT_COLLECTION
|
|
11105
|
+
if collection == PROPOSAL_COLLECTION || collection == PROPOSAL_EVENT_COLLECTION || collection == CONVERGENCE_COLLECTION || collection == INTENT_COLLECTION || collection == INTENT_EVENT_COLLECTION {
|
|
11106
|
+
return Err(ApiError(
|
|
11107
|
+
StatusCode::FORBIDDEN,
|
|
11108
|
+
"system collection requires proposal service".into(),
|
|
11109
|
+
));
|
|
11110
|
+
}
|
|
9491
11111
|
let key = record_key(&collection, &id)?;
|
|
9492
11112
|
let value = state
|
|
9493
11113
|
.db
|
|
@@ -9708,7 +11328,11 @@ async fn commit_transaction(
|
|
|
9708
11328
|
})?;
|
|
9709
11329
|
|
|
9710
11330
|
Ok((
|
|
9711
|
-
if commit.duplicate {
|
|
11331
|
+
if commit.duplicate {
|
|
11332
|
+
StatusCode::OK
|
|
11333
|
+
} else {
|
|
11334
|
+
StatusCode::CREATED
|
|
11335
|
+
},
|
|
9712
11336
|
Json(AtomicTxResponse {
|
|
9713
11337
|
transaction_id: commit.transaction_id,
|
|
9714
11338
|
duplicate: commit.duplicate,
|
|
@@ -9724,7 +11348,12 @@ async fn create_record(
|
|
|
9724
11348
|
Path(collection): Path<String>,
|
|
9725
11349
|
Json(mut record): Json<CreateRecord>,
|
|
9726
11350
|
) -> Result<(StatusCode, Json<RecordResponse>), ApiError> {
|
|
9727
|
-
if collection == PROPOSAL_COLLECTION || collection == PROPOSAL_EVENT_COLLECTION
|
|
11351
|
+
if collection == PROPOSAL_COLLECTION || collection == PROPOSAL_EVENT_COLLECTION || collection == CONVERGENCE_COLLECTION || collection == INTENT_COLLECTION || collection == INTENT_EVENT_COLLECTION {
|
|
11352
|
+
return Err(ApiError(
|
|
11353
|
+
StatusCode::FORBIDDEN,
|
|
11354
|
+
"system collection requires proposal service".into(),
|
|
11355
|
+
));
|
|
11356
|
+
}
|
|
9728
11357
|
validate_segment(&collection)?;
|
|
9729
11358
|
let id = record
|
|
9730
11359
|
.id
|
|
@@ -9758,7 +11387,12 @@ async fn update_record(
|
|
|
9758
11387
|
Path((collection, id)): Path<(String, String)>,
|
|
9759
11388
|
Json(changes): Json<Map<String, Value>>,
|
|
9760
11389
|
) -> Result<Json<RecordResponse>, ApiError> {
|
|
9761
|
-
if collection == PROPOSAL_COLLECTION || collection == PROPOSAL_EVENT_COLLECTION
|
|
11390
|
+
if collection == PROPOSAL_COLLECTION || collection == PROPOSAL_EVENT_COLLECTION || collection == CONVERGENCE_COLLECTION || collection == INTENT_COLLECTION || collection == INTENT_EVENT_COLLECTION {
|
|
11391
|
+
return Err(ApiError(
|
|
11392
|
+
StatusCode::FORBIDDEN,
|
|
11393
|
+
"system collection requires proposal service".into(),
|
|
11394
|
+
));
|
|
11395
|
+
}
|
|
9762
11396
|
let key = record_key(&collection, &id)?;
|
|
9763
11397
|
let current = state
|
|
9764
11398
|
.db
|
|
@@ -9874,7 +11508,12 @@ async fn delete_record(
|
|
|
9874
11508
|
State(state): State<AppState>,
|
|
9875
11509
|
Path((collection, id)): Path<(String, String)>,
|
|
9876
11510
|
) -> Result<StatusCode, ApiError> {
|
|
9877
|
-
if collection == PROPOSAL_COLLECTION || collection == PROPOSAL_EVENT_COLLECTION
|
|
11511
|
+
if collection == PROPOSAL_COLLECTION || collection == PROPOSAL_EVENT_COLLECTION || collection == CONVERGENCE_COLLECTION || collection == INTENT_COLLECTION || collection == INTENT_EVENT_COLLECTION {
|
|
11512
|
+
return Err(ApiError(
|
|
11513
|
+
StatusCode::FORBIDDEN,
|
|
11514
|
+
"system collection requires proposal service".into(),
|
|
11515
|
+
));
|
|
11516
|
+
}
|
|
9878
11517
|
let key = record_key(&collection, &id)?;
|
|
9879
11518
|
if state.db.get_value(&key)?.is_none() {
|
|
9880
11519
|
return Err(ApiError(
|
|
@@ -9891,8 +11530,15 @@ async fn events(
|
|
|
9891
11530
|
Extension(principal): Extension<Principal>,
|
|
9892
11531
|
Query(scope): Query<EventScopeRequest>,
|
|
9893
11532
|
) -> Result<Sse<impl futures_core::Stream<Item = Result<Event, Infallible>>>, ApiError> {
|
|
9894
|
-
if !principal.permits("events:read") {
|
|
9895
|
-
|
|
11533
|
+
if !principal.permits("events:read") {
|
|
11534
|
+
return Err(ApiError(
|
|
11535
|
+
StatusCode::FORBIDDEN,
|
|
11536
|
+
"event access is not permitted".into(),
|
|
11537
|
+
));
|
|
11538
|
+
}
|
|
11539
|
+
if let Some(application_id) = scope.application_id {
|
|
11540
|
+
application_scope(&state, &principal.key_id, &application_id, "events:read")?;
|
|
11541
|
+
}
|
|
9896
11542
|
let mut receiver = state.db.subscribe_changes();
|
|
9897
11543
|
let namespace = state.namespace.clone();
|
|
9898
11544
|
let actor = principal.key_id;
|
|
@@ -9982,7 +11628,10 @@ mod authority_gate_tests {
|
|
|
9982
11628
|
use super::{api_error_body, authorize_transaction_collections, state_authorization};
|
|
9983
11629
|
use axum::http::StatusCode;
|
|
9984
11630
|
use feltdb::{
|
|
9985
|
-
application::{
|
|
11631
|
+
application::{
|
|
11632
|
+
manifest_hash, ApplicationManifest, ApplicationRevision, PolicyDefinition,
|
|
11633
|
+
RevisionStatus,
|
|
11634
|
+
},
|
|
9986
11635
|
application_runtime::{resolve_runtime, ApplicationRuntimeContract, RuntimeInventory},
|
|
9987
11636
|
};
|
|
9988
11637
|
use feltdb_server::auth::Principal;
|
|
@@ -10028,9 +11677,16 @@ mod authority_gate_tests {
|
|
|
10028
11677
|
#[test]
|
|
10029
11678
|
fn no_matching_policy_denies() {
|
|
10030
11679
|
let contract = contract(vec![]);
|
|
10031
|
-
assert!(
|
|
11680
|
+
assert!(
|
|
11681
|
+
state_authorization(&principal(), &contract, "secrets", "read")
|
|
11682
|
+
.capabilities
|
|
11683
|
+
.is_empty()
|
|
11684
|
+
);
|
|
10032
11685
|
let (allowed, _) = authorize_transaction_collections(
|
|
10033
|
-
&principal(),
|
|
11686
|
+
&principal(),
|
|
11687
|
+
&contract,
|
|
11688
|
+
"tenant",
|
|
11689
|
+
&["secrets".into()],
|
|
10034
11690
|
);
|
|
10035
11691
|
assert!(!allowed);
|
|
10036
11692
|
}
|
|
@@ -10039,7 +11695,10 @@ mod authority_gate_tests {
|
|
|
10039
11695
|
fn terminal_wildcard_policy_allows_matching_collection() {
|
|
10040
11696
|
let contract = contract(vec![allow("buzz_*")]);
|
|
10041
11697
|
let (allowed, _) = authorize_transaction_collections(
|
|
10042
|
-
&principal(),
|
|
11698
|
+
&principal(),
|
|
11699
|
+
&contract,
|
|
11700
|
+
"tenant",
|
|
11701
|
+
&["buzz_rooms".into()],
|
|
10043
11702
|
);
|
|
10044
11703
|
assert!(allowed);
|
|
10045
11704
|
}
|
|
@@ -10048,10 +11707,16 @@ mod authority_gate_tests {
|
|
|
10048
11707
|
fn multi_collection_transaction_authorizes_every_operation() {
|
|
10049
11708
|
let contract = contract(vec![allow("alpha"), allow("beta")]);
|
|
10050
11709
|
let (all_allowed, _) = authorize_transaction_collections(
|
|
10051
|
-
&principal(),
|
|
11710
|
+
&principal(),
|
|
11711
|
+
&contract,
|
|
11712
|
+
"tenant",
|
|
11713
|
+
&["alpha".into(), "beta".into()],
|
|
10052
11714
|
);
|
|
10053
11715
|
let (mixed_allowed, _) = authorize_transaction_collections(
|
|
10054
|
-
&principal(),
|
|
11716
|
+
&principal(),
|
|
11717
|
+
&contract,
|
|
11718
|
+
"tenant",
|
|
11719
|
+
&["alpha".into(), "secrets".into()],
|
|
10055
11720
|
);
|
|
10056
11721
|
assert!(all_allowed);
|
|
10057
11722
|
assert!(!mixed_allowed);
|
|
@@ -10061,7 +11726,10 @@ mod authority_gate_tests {
|
|
|
10061
11726
|
fn cross_tenant_transaction_denies() {
|
|
10062
11727
|
let contract = contract(vec![allow("alpha")]);
|
|
10063
11728
|
let (allowed, _) = authorize_transaction_collections(
|
|
10064
|
-
&principal(),
|
|
11729
|
+
&principal(),
|
|
11730
|
+
&contract,
|
|
11731
|
+
"other-tenant",
|
|
11732
|
+
&["alpha".into()],
|
|
10065
11733
|
);
|
|
10066
11734
|
assert!(!allowed);
|
|
10067
11735
|
}
|