@feltdb/core 0.7.3 → 0.8.0
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 +78 -0
- package/dist/application-development.d.ts.map +1 -0
- package/dist/application-development.js +153 -0
- package/dist/application-manifest.d.ts +6 -1
- package/dist/application-manifest.d.ts.map +1 -1
- package/dist/application-manifest.js +10 -3
- package/dist/canonical-application.d.ts +27 -0
- package/dist/canonical-application.d.ts.map +1 -0
- package/dist/canonical-application.js +46 -0
- package/dist/cli/ai-model-config.js +97 -0
- package/dist/cli/cli.js +1 -1
- package/dist/cli/commands.js +664 -120
- 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 +63 -0
- package/dist/cli/publish-engine.js +165 -0
- package/dist/cli/source-sync.js +183 -0
- package/dist/create/cli.js +11 -2
- package/dist/create/create.js +38 -16
- package/dist/create/managed-account.js +82 -11
- package/dist/create/package-versions.js +1 -1
- package/dist/create/server-source/crates/feltdb/src/application.rs +41 -1
- package/dist/create/server-source/crates/feltdb/src/authority_failover.rs +84 -6
- package/dist/create/server-source/crates/feltdb/src/bin/feltdb_node.rs +126 -38
- package/dist/create/server-source/crates/feltdb/src/distributed_transactions.rs +72 -34
- 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/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 +111 -3
- 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 +1960 -143
- 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 +195 -13
- 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/README.md +57 -0
- package/dist/create/template/default-project/agents/activity-assistant.ts +18 -0
- package/dist/create/template/default-project/agents/project-assistant.ts +19 -0
- package/dist/create/template/default-project/capabilities/activity-summary.ts +14 -0
- package/dist/create/template/default-project/capabilities/project-search.ts +16 -0
- package/dist/create/template/default-project/capabilities/project-summary.ts +15 -0
- package/dist/create/template/default-project/feltdb.flow +174 -0
- package/dist/create/template/default-project/src/App.tsx +20 -0
- package/dist/create/template/default-project/src/context/AuthContext.tsx +27 -0
- package/dist/create/template/default-project/src/feltdb.ts +87 -0
- package/dist/create/template/default-project/src/index.tsx +14 -0
- package/dist/create/template/default-project/src/pages/Activity.tsx +7 -0
- package/dist/create/template/default-project/src/pages/Agents.tsx +15 -0
- package/dist/create/template/default-project/src/pages/Dashboard.tsx +17 -0
- package/dist/create/template/default-project/src/pages/Invitations.tsx +11 -0
- package/dist/create/template/default-project/src/pages/Projects.tsx +11 -0
- package/dist/create/template/default-project/src/pages/SignIn.tsx +8 -0
- package/dist/create/template/default-project/src/pages/SignUp.tsx +8 -0
- package/dist/create/template/default-project/src/styles-application.css +21 -0
- package/dist/create/template/default-project/src/styles.css +1 -0
- package/dist/create/template/default-project/workflows/agent-assisted-summary.ts +1 -0
- package/dist/create/template/default-project/workflows/invitation.ts +30 -0
- package/dist/create/template/default-project/workflows/project-created.ts +2 -0
- package/dist/db.d.ts +49 -1
- package/dist/db.d.ts.map +1 -1
- package/dist/db.js +77 -0
- package/dist/error-codes.d.ts +20 -1
- package/dist/error-codes.d.ts.map +1 -1
- package/dist/error-codes.js +25 -0
- package/dist/flowspec.d.ts +1 -0
- package/dist/flowspec.d.ts.map +1 -1
- package/dist/flowspec.js +66 -11
- package/dist/http-client.d.ts +135 -14
- package/dist/http-client.d.ts.map +1 -1
- package/dist/http-client.js +79 -31
- package/dist/http-db.d.ts +15 -2
- package/dist/http-db.d.ts.map +1 -1
- package/dist/http-db.js +113 -5
- 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/module-intent.d.ts +37 -0
- package/dist/module-intent.d.ts.map +1 -0
- package/dist/module-intent.js +81 -0
- package/dist/module.d.ts +76 -0
- package/dist/module.d.ts.map +1 -0
- package/dist/module.js +61 -0
- package/dist/proposal.d.ts +161 -0
- package/dist/proposal.d.ts.map +1 -0
- package/dist/proposal.js +232 -0
- package/dist/studio/app.d.ts +5 -1
- package/dist/studio/app.d.ts.map +1 -1
- package/dist/studio/components/ApplicationDesigner.d.ts +2 -1
- package/dist/studio/components/ApplicationDesigner.d.ts.map +1 -1
- package/dist/studio/components/AskFeltDB.d.ts +21 -0
- package/dist/studio/components/AskFeltDB.d.ts.map +1 -0
- 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 +12 -0
- package/dist/studio/components/ProposalBrowser.d.ts.map +1 -0
- 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 +4 -0
- package/dist/studio/components/index.d.ts.map +1 -1
- package/dist/studio/components/index.js +4 -4
- package/dist/studio/components-CNSNrmA9.js +2787 -0
- package/dist/studio/index.js +214 -157
- 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 +53 -0
- package/dist/studio/utils/proposal-api.d.ts.map +1 -0
- package/dist/studio/utils/proposal-api.js +91 -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/dist-CCOk39Uc.js +1 -0
- package/dist/studio-app/assets/{feltdb_wasm-DVKsw75S.js → feltdb_wasm-CSB6KVgw.js} +1 -1
- package/dist/studio-app/assets/feltdb_wasm_bg-Dd1fnO9U.wasm +0 -0
- package/dist/studio-app/assets/index-Bncji3aN.js +43 -0
- package/dist/studio-app/assets/{index-C1GWyazR.css → index-yazaWMUN.css} +1 -1
- package/dist/studio-app/assets/lib-B2_7fcn7.js +69 -0
- package/dist/studio-app/assets/worker-CARZ5g7K.js +69 -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 +3 -0
- package/dist/workspace/local-development-authority.d.ts.map +1 -1
- package/dist/workspace/local-development-authority.js +124 -2
- package/package.json +1 -1
- package/dist/studio/components-Dxuhrv8_.js +0 -1832
- package/dist/studio-app/assets/feltdb_wasm_bg-DNyNf0yy.wasm +0 -0
- package/dist/studio-app/assets/index-B5tnmvSD.js +0 -28
|
@@ -30,7 +30,8 @@ use axum::{
|
|
|
30
30
|
use base64::Engine;
|
|
31
31
|
use feltdb::{
|
|
32
32
|
application::{
|
|
33
|
-
|
|
33
|
+
diff_manifests, manifest_hash, validate_manifest, ActionOperation, ApplicationManifest,
|
|
34
|
+
ApplicationRevision, ApplicationStore, PreviewDataMode, PreviewPolicy, RevisionStatus,
|
|
34
35
|
},
|
|
35
36
|
application_runtime::{
|
|
36
37
|
resolve_runtime, runtime_readiness, snapshot_state_namespace, validate_runtime,
|
|
@@ -41,12 +42,15 @@ use feltdb::{
|
|
|
41
42
|
Grant, GrantSigner, GrantStore, Subject as GrantSubject,
|
|
42
43
|
},
|
|
43
44
|
cardinality_endpoint::{CardinalityContext, CardinalityDiagnosticResponse},
|
|
44
|
-
|
|
45
|
-
|
|
45
|
+
policy_evaluation::{
|
|
46
|
+
Actor, AuthorizationState, PolicyContext, PolicyEvaluator, PolicySubject,
|
|
47
|
+
RecordAuthorizationContext,
|
|
48
|
+
},
|
|
46
49
|
state_contract::{
|
|
47
50
|
begin_read, compare_schemas, execute_query as execute_state_query, execute_transaction,
|
|
48
|
-
execute_transaction_with_collection_policies, schema_from_revision, validate_schema,
|
|
49
|
-
|
|
51
|
+
execute_transaction_with_collection_policies, schema_from_revision, validate_schema,
|
|
52
|
+
AuthorizationContext, CanonicalQuery, QueryFilter, StateSchema, TransactionOperation,
|
|
53
|
+
TransactionOperationKind, TransactionRequest,
|
|
50
54
|
},
|
|
51
55
|
sync_contract::{
|
|
52
56
|
DeviceIdentity, ReconciliationOutcome, SyncAck, SyncBatch, SyncConflict, SyncCursor,
|
|
@@ -57,7 +61,8 @@ use feltdb::{
|
|
|
57
61
|
WorkerRegistration,
|
|
58
62
|
},
|
|
59
63
|
workload::{CreateWorkload, WorkloadStore},
|
|
60
|
-
DatabaseSnapshot, FeltDb, JsonCasResult, Operation,
|
|
64
|
+
AtomicMutation, DatabaseSnapshot, FeltDb, FlowError, JsonCasResult, Operation,
|
|
65
|
+
PeerAdvertisement, PeerId, RecordPrecondition, StoredRow,
|
|
61
66
|
};
|
|
62
67
|
use feltdb_server::{
|
|
63
68
|
app_state::{AppState, BoundedQueryCursor},
|
|
@@ -109,7 +114,10 @@ impl ApiError {
|
|
|
109
114
|
/// `api_error_body` already parses a message that happens to be JSON, so
|
|
110
115
|
/// this is the existing mechanism named rather than a second one.
|
|
111
116
|
fn structured(status: StatusCode, body: Value) -> Self {
|
|
112
|
-
Self(
|
|
117
|
+
Self(
|
|
118
|
+
status,
|
|
119
|
+
serde_json::to_string(&body).unwrap_or_else(|_| body.to_string()),
|
|
120
|
+
)
|
|
113
121
|
}
|
|
114
122
|
}
|
|
115
123
|
|
|
@@ -121,10 +129,44 @@ impl IntoResponse for ApiError {
|
|
|
121
129
|
}
|
|
122
130
|
|
|
123
131
|
fn api_error_body(status: StatusCode, message: String) -> Value {
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
132
|
+
let request_id = format!("req_{}", uuid::Uuid::new_v4().simple());
|
|
133
|
+
let code = match status {
|
|
134
|
+
StatusCode::UNAUTHORIZED => "AUTHENTICATION_REQUIRED",
|
|
135
|
+
StatusCode::FORBIDDEN => "FORBIDDEN",
|
|
136
|
+
StatusCode::NOT_FOUND => "NOT_FOUND",
|
|
137
|
+
StatusCode::CONFLICT => "CONFLICT",
|
|
138
|
+
StatusCode::TOO_MANY_REQUESTS => "QUERY_LIMIT_EXCEEDED",
|
|
139
|
+
StatusCode::SERVICE_UNAVAILABLE => "SERVICE_UNAVAILABLE",
|
|
140
|
+
StatusCode::BAD_REQUEST | StatusCode::UNPROCESSABLE_ENTITY => "VALIDATION_ERROR",
|
|
141
|
+
_ if status.is_server_error() => "EXECUTION_FAILED",
|
|
142
|
+
_ => "REQUEST_FAILED",
|
|
143
|
+
};
|
|
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();
|
|
159
|
+
object.insert("error".into(), json!(resolved_code));
|
|
160
|
+
object.insert("code".into(), json!(resolved_code));
|
|
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
|
+
});
|
|
168
|
+
object.insert("request_id".into(), json!(request_id));
|
|
169
|
+
body
|
|
128
170
|
}
|
|
129
171
|
|
|
130
172
|
impl From<feltdb::FlowError> for ApiError {
|
|
@@ -390,6 +432,96 @@ struct RuntimeEnvironmentRequest {
|
|
|
390
432
|
environment: String,
|
|
391
433
|
}
|
|
392
434
|
#[derive(Deserialize)]
|
|
435
|
+
struct ApplicationDiscoveryRequest {
|
|
436
|
+
application_id: String,
|
|
437
|
+
#[serde(default = "production_environment")]
|
|
438
|
+
environment: String,
|
|
439
|
+
}
|
|
440
|
+
#[derive(Deserialize)]
|
|
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
|
+
}
|
|
452
|
+
#[derive(Deserialize)]
|
|
453
|
+
struct CreateProposalRequest {
|
|
454
|
+
application_id: String,
|
|
455
|
+
#[serde(default = "production_environment")]
|
|
456
|
+
environment: String,
|
|
457
|
+
base_contract_hash: String,
|
|
458
|
+
base_flow_hash: String,
|
|
459
|
+
proposed_flow: String,
|
|
460
|
+
#[serde(default)]
|
|
461
|
+
proposal_version: Option<u64>,
|
|
462
|
+
#[serde(default)]
|
|
463
|
+
contract_diff: Option<Value>,
|
|
464
|
+
#[serde(default)]
|
|
465
|
+
source_plan: Option<Value>,
|
|
466
|
+
summary: String,
|
|
467
|
+
#[serde(default)]
|
|
468
|
+
warnings: Vec<String>,
|
|
469
|
+
#[serde(default)]
|
|
470
|
+
metadata: Value,
|
|
471
|
+
parent_proposal_id: Option<String>,
|
|
472
|
+
expires_at: Option<String>,
|
|
473
|
+
#[serde(default)]
|
|
474
|
+
status: Option<String>,
|
|
475
|
+
}
|
|
476
|
+
#[derive(Deserialize)]
|
|
477
|
+
struct ProposalActorRequest {
|
|
478
|
+
application_id: String,
|
|
479
|
+
#[serde(default = "production_environment")]
|
|
480
|
+
environment: String,
|
|
481
|
+
proposed_manifest: Option<ApplicationManifest>,
|
|
482
|
+
}
|
|
483
|
+
#[derive(Deserialize)]
|
|
484
|
+
struct ProposalStatusRequest {
|
|
485
|
+
application_id: String,
|
|
486
|
+
#[serde(default = "production_environment")]
|
|
487
|
+
environment: String,
|
|
488
|
+
status: String,
|
|
489
|
+
#[serde(default)]
|
|
490
|
+
approve_authorization: bool,
|
|
491
|
+
#[serde(default)]
|
|
492
|
+
reason: Option<String>,
|
|
493
|
+
}
|
|
494
|
+
#[derive(Deserialize)]
|
|
495
|
+
struct ProposalLifecycleRequest {
|
|
496
|
+
application_id: String,
|
|
497
|
+
#[serde(default = "production_environment")]
|
|
498
|
+
environment: String,
|
|
499
|
+
#[serde(default)]
|
|
500
|
+
approve_authorization: bool,
|
|
501
|
+
#[serde(default)]
|
|
502
|
+
reason: Option<String>,
|
|
503
|
+
}
|
|
504
|
+
#[derive(Deserialize)]
|
|
505
|
+
struct PreviewSimulationRequest {
|
|
506
|
+
event: String,
|
|
507
|
+
}
|
|
508
|
+
#[derive(Deserialize, Default)]
|
|
509
|
+
struct EventScopeRequest {
|
|
510
|
+
application_id: Option<String>,
|
|
511
|
+
}
|
|
512
|
+
#[derive(Deserialize)]
|
|
513
|
+
struct SignUpRequest {
|
|
514
|
+
application_id: String,
|
|
515
|
+
email: String,
|
|
516
|
+
password: String,
|
|
517
|
+
display_name: Option<String>,
|
|
518
|
+
}
|
|
519
|
+
#[derive(Deserialize)]
|
|
520
|
+
struct SignInRequest {
|
|
521
|
+
email: String,
|
|
522
|
+
password: String,
|
|
523
|
+
}
|
|
524
|
+
#[derive(Deserialize)]
|
|
393
525
|
struct StateContractTarget {
|
|
394
526
|
application_id: String,
|
|
395
527
|
revision_id: String,
|
|
@@ -2236,7 +2368,10 @@ async fn delete_certification_fixture(
|
|
|
2236
2368
|
|| ring::constant_time::verify_slices_are_equal(expected.as_bytes(), provided.as_bytes())
|
|
2237
2369
|
.is_err()
|
|
2238
2370
|
{
|
|
2239
|
-
return Err(ApiError(
|
|
2371
|
+
return Err(ApiError(
|
|
2372
|
+
StatusCode::FORBIDDEN,
|
|
2373
|
+
"certification recovery denied".into(),
|
|
2374
|
+
));
|
|
2240
2375
|
}
|
|
2241
2376
|
let tenant_id = state
|
|
2242
2377
|
.tenancy
|
|
@@ -3864,9 +3999,8 @@ async fn commit_application_draft(
|
|
|
3864
3999
|
.commit(&tenant, &application_id, &draft_id, &principal.key_id)
|
|
3865
4000
|
.map_err(manifest_error)?;
|
|
3866
4001
|
let encoded_revision = percent_encode_path_segment(&revision.revision_id);
|
|
3867
|
-
let mut response = serde_json::to_value(revision)
|
|
3868
|
-
ApiError(StatusCode::INTERNAL_SERVER_ERROR, error.to_string())
|
|
3869
|
-
})?;
|
|
4002
|
+
let mut response = serde_json::to_value(revision)
|
|
4003
|
+
.map_err(|error| ApiError(StatusCode::INTERNAL_SERVER_ERROR, error.to_string()))?;
|
|
3870
4004
|
response
|
|
3871
4005
|
.as_object_mut()
|
|
3872
4006
|
.expect("application revisions serialize as objects")
|
|
@@ -4346,62 +4480,1403 @@ fn runtime_inventory(
|
|
|
4346
4480
|
.iter()
|
|
4347
4481
|
.filter(|binding| binding.provider.is_none())
|
|
4348
4482
|
{
|
|
4349
|
-
capabilities.insert(binding.name.clone());
|
|
4483
|
+
capabilities.insert(binding.name.clone());
|
|
4484
|
+
}
|
|
4485
|
+
Ok(RuntimeInventory {
|
|
4486
|
+
capabilities,
|
|
4487
|
+
connections: connection_ids,
|
|
4488
|
+
enforce_availability: true,
|
|
4489
|
+
})
|
|
4490
|
+
}
|
|
4491
|
+
fn revision_contract(
|
|
4492
|
+
state: &AppState,
|
|
4493
|
+
actor: &str,
|
|
4494
|
+
application_id: &str,
|
|
4495
|
+
revision_id: &str,
|
|
4496
|
+
environment: &str,
|
|
4497
|
+
) -> Result<ApplicationRuntimeContract, ApiError> {
|
|
4498
|
+
let tenant = application_scope(state, actor, application_id, "application:revision:read")?;
|
|
4499
|
+
let revision = state
|
|
4500
|
+
.applications
|
|
4501
|
+
.revision(&tenant, application_id, revision_id)
|
|
4502
|
+
.ok_or(ApiError(StatusCode::NOT_FOUND, "revision not found".into()))?;
|
|
4503
|
+
let inventory = runtime_inventory(state, actor, application_id, &revision.manifest)?;
|
|
4504
|
+
resolve_runtime(&revision, environment, &inventory).map_err(|report| {
|
|
4505
|
+
ApiError(
|
|
4506
|
+
StatusCode::CONFLICT,
|
|
4507
|
+
serde_json::to_string(&report).unwrap_or_else(|_| "runtime validation failed".into()),
|
|
4508
|
+
)
|
|
4509
|
+
})
|
|
4510
|
+
}
|
|
4511
|
+
async fn get_active_application_runtime(
|
|
4512
|
+
State(state): State<AppState>,
|
|
4513
|
+
Extension(principal): Extension<Principal>,
|
|
4514
|
+
Path(application_id): Path<String>,
|
|
4515
|
+
Query(input): Query<RuntimeEnvironmentRequest>,
|
|
4516
|
+
) -> Result<Json<Value>, ApiError> {
|
|
4517
|
+
let app = state
|
|
4518
|
+
.tenancy
|
|
4519
|
+
.application_for(&principal.key_id, &application_id)
|
|
4520
|
+
.ok_or(ApiError(
|
|
4521
|
+
StatusCode::NOT_FOUND,
|
|
4522
|
+
"application not found".into(),
|
|
4523
|
+
))?;
|
|
4524
|
+
let pointers = state.applications.pointers(&application_id);
|
|
4525
|
+
let revision = pointers.get(&input.environment).ok_or(ApiError(
|
|
4526
|
+
StatusCode::CONFLICT,
|
|
4527
|
+
"application has no revision promoted to this environment".into(),
|
|
4528
|
+
))?;
|
|
4529
|
+
let contract = revision_contract(
|
|
4530
|
+
&state,
|
|
4531
|
+
&principal.key_id,
|
|
4532
|
+
&application_id,
|
|
4533
|
+
revision,
|
|
4534
|
+
&input.environment,
|
|
4535
|
+
)?;
|
|
4536
|
+
Ok(Json(
|
|
4537
|
+
json!({"contract":contract,"runtime":state.runtimes.active(&app.tenant_id,&application_id,revision,&input.environment)}),
|
|
4538
|
+
))
|
|
4539
|
+
}
|
|
4540
|
+
async fn discover_application(
|
|
4541
|
+
State(state): State<AppState>,
|
|
4542
|
+
Extension(principal): Extension<Principal>,
|
|
4543
|
+
Query(input): Query<ApplicationDiscoveryRequest>,
|
|
4544
|
+
) -> Result<Json<Value>, ApiError> {
|
|
4545
|
+
let tenant = application_scope(
|
|
4546
|
+
&state,
|
|
4547
|
+
&principal.key_id,
|
|
4548
|
+
&input.application_id,
|
|
4549
|
+
"application:read",
|
|
4550
|
+
)?;
|
|
4551
|
+
let revision_id = state
|
|
4552
|
+
.applications
|
|
4553
|
+
.pointers(&input.application_id)
|
|
4554
|
+
.get(&input.environment)
|
|
4555
|
+
.cloned()
|
|
4556
|
+
.ok_or(ApiError(
|
|
4557
|
+
StatusCode::NOT_FOUND,
|
|
4558
|
+
"application environment has no active contract".into(),
|
|
4559
|
+
))?;
|
|
4560
|
+
let revision = state
|
|
4561
|
+
.applications
|
|
4562
|
+
.revision(&tenant, &input.application_id, &revision_id)
|
|
4563
|
+
.ok_or(ApiError(
|
|
4564
|
+
StatusCode::NOT_FOUND,
|
|
4565
|
+
"application revision not found".into(),
|
|
4566
|
+
))?;
|
|
4567
|
+
let contract_hash = revision
|
|
4568
|
+
.manifest
|
|
4569
|
+
.metadata
|
|
4570
|
+
.labels
|
|
4571
|
+
.get("feltdb.contract_hash")
|
|
4572
|
+
.unwrap_or(&revision.manifest_hash);
|
|
4573
|
+
let dsl_version = revision
|
|
4574
|
+
.manifest
|
|
4575
|
+
.metadata
|
|
4576
|
+
.labels
|
|
4577
|
+
.get("feltdb.dsl_version")
|
|
4578
|
+
.and_then(|value| value.parse::<u32>().ok())
|
|
4579
|
+
.unwrap_or(1);
|
|
4580
|
+
let application_name = state
|
|
4581
|
+
.tenancy
|
|
4582
|
+
.application_for(&principal.key_id, &input.application_id)
|
|
4583
|
+
.map(|application| application.name);
|
|
4584
|
+
let flow_hash = revision.manifest.metadata.labels.get("feltdb.flow_hash");
|
|
4585
|
+
Ok(Json(json!({
|
|
4586
|
+
"application_id": input.application_id,
|
|
4587
|
+
"application_name": application_name,
|
|
4588
|
+
"environment": input.environment,
|
|
4589
|
+
"version": revision.revision_number,
|
|
4590
|
+
"revision_id": revision.revision_id,
|
|
4591
|
+
"contract_hash": contract_hash,
|
|
4592
|
+
"flow_hash": flow_hash,
|
|
4593
|
+
"dsl_version": dsl_version,
|
|
4594
|
+
"snapshot": {
|
|
4595
|
+
"application": { "application_id": input.application_id, "environment": input.environment },
|
|
4596
|
+
"contract_version": revision.revision_number,
|
|
4597
|
+
"contract_hash": contract_hash,
|
|
4598
|
+
"dsl_version": dsl_version,
|
|
4599
|
+
"schema": revision.manifest.collections,
|
|
4600
|
+
"indexes": revision.manifest.indexes,
|
|
4601
|
+
"policies": revision.manifest.policies,
|
|
4602
|
+
"workflows": revision.manifest.workflows,
|
|
4603
|
+
"agents": revision.manifest.agents,
|
|
4604
|
+
"capabilities": revision.manifest.capabilities,
|
|
4605
|
+
"modules": revision.manifest.modules,
|
|
4606
|
+
"supported_primitives": ["text","integer","number","boolean","datetime","date","time","json","uuid","decimal","money","bigint","email","url","phone","binary","file","geo","object"]
|
|
4607
|
+
}
|
|
4608
|
+
})))
|
|
4609
|
+
}
|
|
4610
|
+
|
|
4611
|
+
const DEFAULT_INSPECTION_LIMIT: usize = 50;
|
|
4612
|
+
const MAX_INSPECTION_LIMIT: usize = 100;
|
|
4613
|
+
fn inspection_error(status: StatusCode, code: &str, message: impl Into<String>) -> ApiError {
|
|
4614
|
+
ApiError::structured(status, json!({"code":code,"message":message.into()}))
|
|
4615
|
+
}
|
|
4616
|
+
fn active_inspection_contract(
|
|
4617
|
+
state: &AppState,
|
|
4618
|
+
principal: &Principal,
|
|
4619
|
+
application_id: &str,
|
|
4620
|
+
environment: &str,
|
|
4621
|
+
) -> Result<ApplicationRuntimeContract, ApiError> {
|
|
4622
|
+
application_scope(state, &principal.key_id, application_id, "state:read")?;
|
|
4623
|
+
let revision = state
|
|
4624
|
+
.applications
|
|
4625
|
+
.pointers(application_id)
|
|
4626
|
+
.get(environment)
|
|
4627
|
+
.cloned()
|
|
4628
|
+
.ok_or_else(|| {
|
|
4629
|
+
inspection_error(
|
|
4630
|
+
StatusCode::NOT_FOUND,
|
|
4631
|
+
"collection_not_found",
|
|
4632
|
+
"application environment has no active contract",
|
|
4633
|
+
)
|
|
4634
|
+
})?;
|
|
4635
|
+
revision_contract(
|
|
4636
|
+
state,
|
|
4637
|
+
&principal.key_id,
|
|
4638
|
+
application_id,
|
|
4639
|
+
&revision,
|
|
4640
|
+
environment,
|
|
4641
|
+
)
|
|
4642
|
+
}
|
|
4643
|
+
fn inspectable_collection<'a>(
|
|
4644
|
+
contract: &'a ApplicationRuntimeContract,
|
|
4645
|
+
collection: &str,
|
|
4646
|
+
) -> Result<&'a feltdb::state_contract::CollectionSchema, ApiError> {
|
|
4647
|
+
if collection.starts_with("_feltdb") {
|
|
4648
|
+
return Err(inspection_error(
|
|
4649
|
+
StatusCode::FORBIDDEN,
|
|
4650
|
+
"system_collection",
|
|
4651
|
+
"system collections require their dedicated Service API",
|
|
4652
|
+
));
|
|
4653
|
+
}
|
|
4654
|
+
contract
|
|
4655
|
+
.state
|
|
4656
|
+
.schema
|
|
4657
|
+
.collections
|
|
4658
|
+
.iter()
|
|
4659
|
+
.find(|value| value.name == collection)
|
|
4660
|
+
.ok_or_else(|| {
|
|
4661
|
+
inspection_error(
|
|
4662
|
+
StatusCode::NOT_FOUND,
|
|
4663
|
+
"collection_not_found",
|
|
4664
|
+
format!("collection {collection} is not part of the active application contract"),
|
|
4665
|
+
)
|
|
4666
|
+
})
|
|
4667
|
+
}
|
|
4668
|
+
fn secret_name(name: &str) -> bool {
|
|
4669
|
+
let value = name.to_ascii_lowercase();
|
|
4670
|
+
[
|
|
4671
|
+
"secret",
|
|
4672
|
+
"password",
|
|
4673
|
+
"token",
|
|
4674
|
+
"api_key",
|
|
4675
|
+
"apikey",
|
|
4676
|
+
"credential",
|
|
4677
|
+
"private_key",
|
|
4678
|
+
]
|
|
4679
|
+
.iter()
|
|
4680
|
+
.any(|part| value.contains(part))
|
|
4681
|
+
}
|
|
4682
|
+
fn redact_inspection_value(value: &mut Value) {
|
|
4683
|
+
match value {
|
|
4684
|
+
Value::Object(object) => {
|
|
4685
|
+
for (key, child) in object.iter_mut() {
|
|
4686
|
+
if key.eq_ignore_ascii_case("secrets") && child.is_array() {
|
|
4687
|
+
redact_inspection_value(child)
|
|
4688
|
+
} else if secret_name(key) {
|
|
4689
|
+
*child = json!("[REDACTED]")
|
|
4690
|
+
} else {
|
|
4691
|
+
redact_inspection_value(child)
|
|
4692
|
+
}
|
|
4693
|
+
}
|
|
4694
|
+
}
|
|
4695
|
+
Value::Array(values) => {
|
|
4696
|
+
for child in values {
|
|
4697
|
+
redact_inspection_value(child)
|
|
4698
|
+
}
|
|
4699
|
+
}
|
|
4700
|
+
Value::String(text)
|
|
4701
|
+
if text.contains("sk_live_")
|
|
4702
|
+
|| text.contains("sk_test_")
|
|
4703
|
+
|| text.contains("whsec_")
|
|
4704
|
+
|| text.contains("-----BEGIN PRIVATE KEY-----") =>
|
|
4705
|
+
{
|
|
4706
|
+
*value = json!("[REDACTED]")
|
|
4707
|
+
}
|
|
4708
|
+
_ => {}
|
|
4709
|
+
}
|
|
4710
|
+
}
|
|
4711
|
+
fn relationship_metadata(
|
|
4712
|
+
contract: &ApplicationRuntimeContract,
|
|
4713
|
+
collection: &str,
|
|
4714
|
+
record: &Value,
|
|
4715
|
+
) -> Vec<Value> {
|
|
4716
|
+
let Some(definition) = contract
|
|
4717
|
+
.state
|
|
4718
|
+
.collections
|
|
4719
|
+
.iter()
|
|
4720
|
+
.find(|value| value.name == collection)
|
|
4721
|
+
else {
|
|
4722
|
+
return vec![];
|
|
4723
|
+
};
|
|
4724
|
+
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()
|
|
4725
|
+
}
|
|
4726
|
+
fn inspection_cursor(
|
|
4727
|
+
collection: &str,
|
|
4728
|
+
state_version: u64,
|
|
4729
|
+
after: &str,
|
|
4730
|
+
filter_hash: &str,
|
|
4731
|
+
) -> String {
|
|
4732
|
+
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())
|
|
4733
|
+
}
|
|
4734
|
+
fn parse_inspection_cursor(
|
|
4735
|
+
cursor: &str,
|
|
4736
|
+
collection: &str,
|
|
4737
|
+
current_version: u64,
|
|
4738
|
+
filter_hash: &str,
|
|
4739
|
+
) -> Result<String, ApiError> {
|
|
4740
|
+
let value = base64::engine::general_purpose::URL_SAFE_NO_PAD
|
|
4741
|
+
.decode(cursor)
|
|
4742
|
+
.ok()
|
|
4743
|
+
.and_then(|bytes| serde_json::from_slice::<Value>(&bytes).ok())
|
|
4744
|
+
.ok_or_else(|| {
|
|
4745
|
+
inspection_error(
|
|
4746
|
+
StatusCode::UNPROCESSABLE_ENTITY,
|
|
4747
|
+
"invalid_cursor",
|
|
4748
|
+
"cursor is malformed",
|
|
4749
|
+
)
|
|
4750
|
+
})?;
|
|
4751
|
+
let expected = format!(
|
|
4752
|
+
"{:x}",
|
|
4753
|
+
Sha256::digest(format!("{collection}:{filter_hash}").as_bytes())
|
|
4754
|
+
);
|
|
4755
|
+
if value.get("v").and_then(Value::as_u64) != Some(1)
|
|
4756
|
+
|| value.get("state").and_then(Value::as_u64) != Some(current_version)
|
|
4757
|
+
|| value.get("query").and_then(Value::as_str) != Some(&expected)
|
|
4758
|
+
{
|
|
4759
|
+
return Err(inspection_error(
|
|
4760
|
+
StatusCode::UNPROCESSABLE_ENTITY,
|
|
4761
|
+
"invalid_cursor",
|
|
4762
|
+
"cursor is stale or belongs to another query",
|
|
4763
|
+
));
|
|
4764
|
+
}
|
|
4765
|
+
value
|
|
4766
|
+
.get("after")
|
|
4767
|
+
.and_then(Value::as_str)
|
|
4768
|
+
.map(str::to_string)
|
|
4769
|
+
.ok_or_else(|| {
|
|
4770
|
+
inspection_error(
|
|
4771
|
+
StatusCode::UNPROCESSABLE_ENTITY,
|
|
4772
|
+
"invalid_cursor",
|
|
4773
|
+
"cursor position is invalid",
|
|
4774
|
+
)
|
|
4775
|
+
})
|
|
4776
|
+
}
|
|
4777
|
+
async fn inspect_application_schema(
|
|
4778
|
+
State(state): State<AppState>,
|
|
4779
|
+
Extension(principal): Extension<Principal>,
|
|
4780
|
+
Query(input): Query<ApplicationDiscoveryRequest>,
|
|
4781
|
+
) -> Result<Json<Value>, ApiError> {
|
|
4782
|
+
let contract = active_inspection_contract(
|
|
4783
|
+
&state,
|
|
4784
|
+
&principal,
|
|
4785
|
+
&input.application_id,
|
|
4786
|
+
&input.environment,
|
|
4787
|
+
)?;
|
|
4788
|
+
let collections=contract.state.collections.iter().map(|collection|{let policy=matching_state_policy(&contract,&collection.name);
|
|
4789
|
+
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<_>>();
|
|
4790
|
+
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<_>>();
|
|
4791
|
+
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<_>>();
|
|
4792
|
+
Ok(Json(
|
|
4793
|
+
json!({"application":{"application_id":input.application_id,"environment":input.environment},"contract_version":contract.revision_number,"collections":collections}),
|
|
4794
|
+
))
|
|
4795
|
+
}
|
|
4796
|
+
async fn inspect_collection(
|
|
4797
|
+
State(state): State<AppState>,
|
|
4798
|
+
Extension(principal): Extension<Principal>,
|
|
4799
|
+
Path(collection): Path<String>,
|
|
4800
|
+
Query(params): Query<HashMap<String, String>>,
|
|
4801
|
+
) -> Result<Json<Value>, ApiError> {
|
|
4802
|
+
let application_id = params.get("application_id").ok_or_else(|| {
|
|
4803
|
+
inspection_error(
|
|
4804
|
+
StatusCode::BAD_REQUEST,
|
|
4805
|
+
"inspection_not_allowed",
|
|
4806
|
+
"application_id is required",
|
|
4807
|
+
)
|
|
4808
|
+
})?;
|
|
4809
|
+
let environment = params
|
|
4810
|
+
.get("environment")
|
|
4811
|
+
.map(String::as_str)
|
|
4812
|
+
.unwrap_or("production");
|
|
4813
|
+
let contract = active_inspection_contract(&state, &principal, application_id, environment)?;
|
|
4814
|
+
let definition = inspectable_collection(&contract, &collection)?;
|
|
4815
|
+
let limit = params
|
|
4816
|
+
.get("limit")
|
|
4817
|
+
.map(|value| value.parse::<usize>())
|
|
4818
|
+
.transpose()
|
|
4819
|
+
.map_err(|_| {
|
|
4820
|
+
inspection_error(
|
|
4821
|
+
StatusCode::UNPROCESSABLE_ENTITY,
|
|
4822
|
+
"invalid_pagination",
|
|
4823
|
+
"limit must be an integer",
|
|
4824
|
+
)
|
|
4825
|
+
})?
|
|
4826
|
+
.unwrap_or(DEFAULT_INSPECTION_LIMIT);
|
|
4827
|
+
if limit == 0 || limit > MAX_INSPECTION_LIMIT {
|
|
4828
|
+
return Err(inspection_error(
|
|
4829
|
+
StatusCode::UNPROCESSABLE_ENTITY,
|
|
4830
|
+
"invalid_pagination",
|
|
4831
|
+
format!("limit must be between 1 and {MAX_INSPECTION_LIMIT}"),
|
|
4832
|
+
));
|
|
4833
|
+
}
|
|
4834
|
+
let reserved = ["application_id", "environment", "limit", "cursor"];
|
|
4835
|
+
let mut filters = Vec::new();
|
|
4836
|
+
for (field, value) in params
|
|
4837
|
+
.iter()
|
|
4838
|
+
.filter(|(key, _)| !reserved.contains(&key.as_str()))
|
|
4839
|
+
{
|
|
4840
|
+
if field.contains('[') || field.contains(']') {
|
|
4841
|
+
return Err(inspection_error(
|
|
4842
|
+
StatusCode::UNPROCESSABLE_ENTITY,
|
|
4843
|
+
"unsupported_filter",
|
|
4844
|
+
format!("unsupported filter operator in {field}"),
|
|
4845
|
+
));
|
|
4846
|
+
}
|
|
4847
|
+
if !definition
|
|
4848
|
+
.fields
|
|
4849
|
+
.iter()
|
|
4850
|
+
.any(|candidate| candidate.name == *field)
|
|
4851
|
+
{
|
|
4852
|
+
return Err(inspection_error(
|
|
4853
|
+
StatusCode::UNPROCESSABLE_ENTITY,
|
|
4854
|
+
"field_not_found",
|
|
4855
|
+
format!("field {field} is not in collection {collection}"),
|
|
4856
|
+
));
|
|
4857
|
+
}
|
|
4858
|
+
filters.push(QueryFilter::Eq {
|
|
4859
|
+
field: field.clone(),
|
|
4860
|
+
value: Value::String(value.clone()),
|
|
4861
|
+
});
|
|
4862
|
+
}
|
|
4863
|
+
filters.sort_by(|left, right| format!("{left:?}").cmp(&format!("{right:?}")));
|
|
4864
|
+
let filter_hash = format!(
|
|
4865
|
+
"{:x}",
|
|
4866
|
+
Sha256::digest(serde_json::to_vec(&filters).unwrap_or_default())
|
|
4867
|
+
);
|
|
4868
|
+
let state_version = state.db.sequence()?;
|
|
4869
|
+
let mut after = params
|
|
4870
|
+
.get("cursor")
|
|
4871
|
+
.map(|value| parse_inspection_cursor(value, &collection, state_version, &filter_hash))
|
|
4872
|
+
.transpose()?;
|
|
4873
|
+
let policy = matching_state_policy(&contract, &collection).ok_or_else(|| {
|
|
4874
|
+
inspection_error(
|
|
4875
|
+
StatusCode::FORBIDDEN,
|
|
4876
|
+
"inspection_not_allowed",
|
|
4877
|
+
"collection has no inspection policy",
|
|
4878
|
+
)
|
|
4879
|
+
})?;
|
|
4880
|
+
let read_policy = policy.read.as_deref().and_then(PolicySubject::from_str);
|
|
4881
|
+
if read_policy.is_none()
|
|
4882
|
+
&& !policy
|
|
4883
|
+
.capabilities
|
|
4884
|
+
.iter()
|
|
4885
|
+
.any(|value| value == "state:read")
|
|
4886
|
+
{
|
|
4887
|
+
return Err(inspection_error(
|
|
4888
|
+
StatusCode::FORBIDDEN,
|
|
4889
|
+
"forbidden",
|
|
4890
|
+
"application policy denies collection inspection",
|
|
4891
|
+
));
|
|
4892
|
+
}
|
|
4893
|
+
let auth_state = if matches!(read_policy, Some(PolicySubject::Member)) {
|
|
4894
|
+
Some(AuthorizationState::new(
|
|
4895
|
+
std::sync::Arc::new(contract.state.schema.clone()),
|
|
4896
|
+
contract.environment.state_namespace.clone(),
|
|
4897
|
+
std::sync::Arc::new(state.db.state_rows()?),
|
|
4898
|
+
))
|
|
4899
|
+
} else {
|
|
4900
|
+
None
|
|
4901
|
+
};
|
|
4902
|
+
let capability = format!("{}:{}", contract.environment.state_namespace, collection);
|
|
4903
|
+
let mut accepted: Vec<(String, Value)> = Vec::new();
|
|
4904
|
+
let mut exhausted = false;
|
|
4905
|
+
while accepted.len() <= limit && !exhausted {
|
|
4906
|
+
let page = state
|
|
4907
|
+
.db
|
|
4908
|
+
.list_collection_page(&capability, after.as_deref(), 256)?;
|
|
4909
|
+
if page.len() < 256 {
|
|
4910
|
+
exhausted = true;
|
|
4911
|
+
}
|
|
4912
|
+
if page.is_empty() {
|
|
4913
|
+
break;
|
|
4914
|
+
}
|
|
4915
|
+
for row in page {
|
|
4916
|
+
after = Some(row.key.clone());
|
|
4917
|
+
if !filters.iter().all(|filter| match filter {
|
|
4918
|
+
QueryFilter::Eq { field, value } => row.value.get(field) == Some(value),
|
|
4919
|
+
_ => false,
|
|
4920
|
+
}) {
|
|
4921
|
+
continue;
|
|
4922
|
+
}
|
|
4923
|
+
if let Some(subject) = read_policy.clone() {
|
|
4924
|
+
let mut context = RecordAuthorizationContext::new(
|
|
4925
|
+
Some(Actor::new(&principal.key_id)),
|
|
4926
|
+
collection.clone(),
|
|
4927
|
+
row.key.clone(),
|
|
4928
|
+
row.value.clone(),
|
|
4929
|
+
);
|
|
4930
|
+
if let Some(snapshot) = auth_state.clone() {
|
|
4931
|
+
context = context.with_state(snapshot);
|
|
4932
|
+
}
|
|
4933
|
+
if PolicyEvaluator::evaluate_record(subject, &context).is_err() {
|
|
4934
|
+
continue;
|
|
4935
|
+
}
|
|
4936
|
+
}
|
|
4937
|
+
accepted.push((row.key, row.value));
|
|
4938
|
+
if accepted.len() > limit {
|
|
4939
|
+
break;
|
|
4940
|
+
}
|
|
4941
|
+
}
|
|
4942
|
+
}
|
|
4943
|
+
let has_more = accepted.len() > limit;
|
|
4944
|
+
let last_key = accepted
|
|
4945
|
+
.get(limit.saturating_sub(1))
|
|
4946
|
+
.map(|value| value.0.clone());
|
|
4947
|
+
let records = accepted
|
|
4948
|
+
.into_iter()
|
|
4949
|
+
.take(limit)
|
|
4950
|
+
.map(|(id, mut record)| {
|
|
4951
|
+
let relationships = relationship_metadata(&contract, &collection, &record);
|
|
4952
|
+
redact_inspection_value(&mut record);
|
|
4953
|
+
if let Some(object) = record.as_object_mut() {
|
|
4954
|
+
object.entry("id").or_insert(json!(id));
|
|
4955
|
+
object.insert("_relationships".into(), json!(relationships));
|
|
4956
|
+
}
|
|
4957
|
+
record
|
|
4958
|
+
})
|
|
4959
|
+
.collect::<Vec<_>>();
|
|
4960
|
+
Ok(Json(
|
|
4961
|
+
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}),
|
|
4962
|
+
))
|
|
4963
|
+
}
|
|
4964
|
+
async fn inspect_record(
|
|
4965
|
+
State(state): State<AppState>,
|
|
4966
|
+
Extension(principal): Extension<Principal>,
|
|
4967
|
+
Path((collection, id)): Path<(String, String)>,
|
|
4968
|
+
Query(params): Query<HashMap<String, String>>,
|
|
4969
|
+
) -> Result<Json<Value>, ApiError> {
|
|
4970
|
+
let application_id = params.get("application_id").ok_or_else(|| {
|
|
4971
|
+
inspection_error(
|
|
4972
|
+
StatusCode::BAD_REQUEST,
|
|
4973
|
+
"inspection_not_allowed",
|
|
4974
|
+
"application_id is required",
|
|
4975
|
+
)
|
|
4976
|
+
})?;
|
|
4977
|
+
let environment = params
|
|
4978
|
+
.get("environment")
|
|
4979
|
+
.map(String::as_str)
|
|
4980
|
+
.unwrap_or("production");
|
|
4981
|
+
let contract = active_inspection_contract(&state, &principal, application_id, environment)?;
|
|
4982
|
+
inspectable_collection(&contract, &collection)?;
|
|
4983
|
+
let policy = matching_state_policy(&contract, &collection).ok_or_else(|| {
|
|
4984
|
+
inspection_error(
|
|
4985
|
+
StatusCode::FORBIDDEN,
|
|
4986
|
+
"inspection_not_allowed",
|
|
4987
|
+
"collection has no inspection policy",
|
|
4988
|
+
)
|
|
4989
|
+
})?;
|
|
4990
|
+
let read_policy = policy.read.as_deref().and_then(PolicySubject::from_str);
|
|
4991
|
+
if read_policy.is_none()
|
|
4992
|
+
&& !policy
|
|
4993
|
+
.capabilities
|
|
4994
|
+
.iter()
|
|
4995
|
+
.any(|value| value == "state:read")
|
|
4996
|
+
{
|
|
4997
|
+
return Err(inspection_error(
|
|
4998
|
+
StatusCode::FORBIDDEN,
|
|
4999
|
+
"forbidden",
|
|
5000
|
+
"application policy denies record inspection",
|
|
5001
|
+
));
|
|
5002
|
+
}
|
|
5003
|
+
let capability = format!("{}:{}", contract.environment.state_namespace, collection);
|
|
5004
|
+
let row = state
|
|
5005
|
+
.db
|
|
5006
|
+
.get_collection_record(&capability, &id)?
|
|
5007
|
+
.ok_or_else(|| {
|
|
5008
|
+
inspection_error(
|
|
5009
|
+
StatusCode::NOT_FOUND,
|
|
5010
|
+
"record_not_found",
|
|
5011
|
+
"record not found or is not authorized",
|
|
5012
|
+
)
|
|
5013
|
+
})?;
|
|
5014
|
+
if let Some(subject) = read_policy {
|
|
5015
|
+
let mut context = RecordAuthorizationContext::new(
|
|
5016
|
+
Some(Actor::new(&principal.key_id)),
|
|
5017
|
+
collection.clone(),
|
|
5018
|
+
id.clone(),
|
|
5019
|
+
row.value.clone(),
|
|
5020
|
+
);
|
|
5021
|
+
if matches!(subject, PolicySubject::Member) {
|
|
5022
|
+
context = context.with_state(AuthorizationState::new(
|
|
5023
|
+
std::sync::Arc::new(contract.state.schema.clone()),
|
|
5024
|
+
contract.environment.state_namespace.clone(),
|
|
5025
|
+
std::sync::Arc::new(state.db.state_rows()?),
|
|
5026
|
+
));
|
|
5027
|
+
}
|
|
5028
|
+
if PolicyEvaluator::evaluate_record(subject, &context).is_err() {
|
|
5029
|
+
return Err(inspection_error(
|
|
5030
|
+
StatusCode::NOT_FOUND,
|
|
5031
|
+
"record_not_found",
|
|
5032
|
+
"record not found or is not authorized",
|
|
5033
|
+
));
|
|
5034
|
+
}
|
|
5035
|
+
}
|
|
5036
|
+
let mut record = row.value;
|
|
5037
|
+
let relationships = relationship_metadata(&contract, &collection, &record);
|
|
5038
|
+
redact_inspection_value(&mut record);
|
|
5039
|
+
if let Some(object) = record.as_object_mut() {
|
|
5040
|
+
object.insert("id".into(), json!(id));
|
|
5041
|
+
}
|
|
5042
|
+
Ok(Json(
|
|
5043
|
+
json!({"collection":collection,"record":record,"relationships":relationships}),
|
|
5044
|
+
))
|
|
5045
|
+
}
|
|
5046
|
+
const PROPOSAL_COLLECTION: &str = "_feltdb_proposals";
|
|
5047
|
+
const PROPOSAL_EVENT_COLLECTION: &str = "_feltdb_proposal_events";
|
|
5048
|
+
const PROPOSAL_PREVIEW_COLLECTION: &str = "_feltdb_proposal_previews";
|
|
5049
|
+
|
|
5050
|
+
fn proposal_contract_snapshot(
|
|
5051
|
+
revision: &ApplicationRevision,
|
|
5052
|
+
environment: &str,
|
|
5053
|
+
) -> Value {
|
|
5054
|
+
let contract_hash = revision.manifest.metadata.labels.get("feltdb.contract_hash").unwrap_or(&revision.manifest_hash);
|
|
5055
|
+
let flow_hash = revision.manifest.metadata.labels.get("feltdb.flow_hash").cloned();
|
|
5056
|
+
let dsl_version = revision.manifest.metadata.labels.get("feltdb.dsl_version").and_then(|value| value.parse::<u32>().ok()).unwrap_or(1);
|
|
5057
|
+
json!({
|
|
5058
|
+
"application":{"application_id":revision.application_id,"environment":environment},
|
|
5059
|
+
"contract_version":revision.revision_number,"contract_hash":contract_hash,"flow_hash":flow_hash,
|
|
5060
|
+
"dsl_version":dsl_version,"schema":revision.manifest.collections,"indexes":revision.manifest.indexes,
|
|
5061
|
+
"policies":revision.manifest.policies,"workflows":revision.manifest.workflows,"agents":revision.manifest.agents,
|
|
5062
|
+
"capabilities":revision.manifest.capabilities,"modules":revision.manifest.modules,
|
|
5063
|
+
"supported_primitives":["text","integer","number","boolean","datetime","date","time","json","uuid","decimal","money","bigint","email","url","phone","binary","file","geo","object"]
|
|
5064
|
+
})
|
|
5065
|
+
}
|
|
5066
|
+
|
|
5067
|
+
fn proposal_hash(value: &Value) -> String {
|
|
5068
|
+
format!("sha256:{:x}", Sha256::digest(serde_json::to_vec(value).unwrap_or_default()))
|
|
5069
|
+
}
|
|
5070
|
+
|
|
5071
|
+
fn preview_ttl_seconds() -> i64 {
|
|
5072
|
+
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)
|
|
5073
|
+
}
|
|
5074
|
+
|
|
5075
|
+
fn preview_artifact(state: &AppState, id: &str) -> Result<Value, ApiError> {
|
|
5076
|
+
required_value(state, PROPOSAL_PREVIEW_COLLECTION, id, "preview not found")
|
|
5077
|
+
}
|
|
5078
|
+
|
|
5079
|
+
fn preview_available(value: &Value) -> bool {
|
|
5080
|
+
value.get("status").and_then(Value::as_str) == Some("ready")
|
|
5081
|
+
&& value.get("expires_at").and_then(Value::as_i64).is_some_and(|expires| expires > unix_seconds_i64())
|
|
5082
|
+
}
|
|
5083
|
+
|
|
5084
|
+
fn proposal_event(
|
|
5085
|
+
state: &AppState,
|
|
5086
|
+
proposal_id: &str,
|
|
5087
|
+
from: Option<&str>,
|
|
5088
|
+
to: &str,
|
|
5089
|
+
actor: &str,
|
|
5090
|
+
) -> Result<(), ApiError> {
|
|
5091
|
+
let id = format!("pe_{}", uuid::Uuid::new_v4().simple());
|
|
5092
|
+
let proposal = proposal_value(state, proposal_id).unwrap_or(Value::Null);
|
|
5093
|
+
let event_sequence = state.db.sequence()?.saturating_add(1);
|
|
5094
|
+
insert_canonical(
|
|
5095
|
+
state,
|
|
5096
|
+
PROPOSAL_EVENT_COLLECTION,
|
|
5097
|
+
&id,
|
|
5098
|
+
json!({ "id":id, "event_id":id, "proposal_id":proposal_id, "event_type":to,
|
|
5099
|
+
"from_status":from, "to_status":to, "actor":actor, "created_at":unix_seconds_i64(),
|
|
5100
|
+
"timestamp":unix_seconds_i64(), "event_sequence":event_sequence, "contract_hash":proposal.get("base_contract_hash"),
|
|
5101
|
+
"flow_hash":proposal.get("base_flow_hash"), "metadata":{} }),
|
|
5102
|
+
)?;
|
|
5103
|
+
Ok(())
|
|
5104
|
+
}
|
|
5105
|
+
fn proposal_value(state: &AppState, id: &str) -> Result<Value, ApiError> {
|
|
5106
|
+
required_value(state, PROPOSAL_COLLECTION, id, "proposal not found")
|
|
5107
|
+
}
|
|
5108
|
+
fn proposal_scope_matches(value: &Value, application_id: &str, environment: &str) -> bool {
|
|
5109
|
+
value.get("application_id").and_then(Value::as_str) == Some(application_id)
|
|
5110
|
+
&& value.get("environment").and_then(Value::as_str) == Some(environment)
|
|
5111
|
+
}
|
|
5112
|
+
async fn create_proposal(
|
|
5113
|
+
State(state): State<AppState>,
|
|
5114
|
+
Extension(principal): Extension<Principal>,
|
|
5115
|
+
Json(input): Json<CreateProposalRequest>,
|
|
5116
|
+
) -> Result<(StatusCode, Json<Value>), ApiError> {
|
|
5117
|
+
if input.status.is_some() {
|
|
5118
|
+
return Err(inspection_error(StatusCode::BAD_REQUEST, "invalid_proposal", "proposal lifecycle state is server-controlled"));
|
|
5119
|
+
}
|
|
5120
|
+
if input.proposal_version.unwrap_or(1) != 1 {
|
|
5121
|
+
return Err(inspection_error(StatusCode::UNPROCESSABLE_ENTITY, "invalid_proposal", "proposal_version must be 1"));
|
|
5122
|
+
}
|
|
5123
|
+
if input.contract_diff.as_ref().is_none_or(|value| !value.is_array())
|
|
5124
|
+
|| input.source_plan.as_ref().is_none_or(|value| !value.get("files").is_some_and(Value::is_array))
|
|
5125
|
+
{
|
|
5126
|
+
return Err(inspection_error(StatusCode::UNPROCESSABLE_ENTITY, "invalid_proposal", "contract_diff and source_plan.files are required arrays"));
|
|
5127
|
+
}
|
|
5128
|
+
let tenant = application_scope(
|
|
5129
|
+
&state,
|
|
5130
|
+
&principal.key_id,
|
|
5131
|
+
&input.application_id,
|
|
5132
|
+
"application:write",
|
|
5133
|
+
)?;
|
|
5134
|
+
let revision_id = state
|
|
5135
|
+
.applications
|
|
5136
|
+
.pointers(&input.application_id)
|
|
5137
|
+
.get(&input.environment)
|
|
5138
|
+
.cloned()
|
|
5139
|
+
.ok_or(ApiError(
|
|
5140
|
+
StatusCode::NOT_FOUND,
|
|
5141
|
+
"application environment has no active contract".into(),
|
|
5142
|
+
))?;
|
|
5143
|
+
let revision = state
|
|
5144
|
+
.applications
|
|
5145
|
+
.revision(&tenant, &input.application_id, &revision_id)
|
|
5146
|
+
.ok_or(ApiError(
|
|
5147
|
+
StatusCode::NOT_FOUND,
|
|
5148
|
+
"application revision not found".into(),
|
|
5149
|
+
))?;
|
|
5150
|
+
let contract_hash = revision
|
|
5151
|
+
.manifest
|
|
5152
|
+
.metadata
|
|
5153
|
+
.labels
|
|
5154
|
+
.get("feltdb.contract_hash")
|
|
5155
|
+
.unwrap_or(&revision.manifest_hash);
|
|
5156
|
+
if input.base_contract_hash != *contract_hash {
|
|
5157
|
+
return Err(ApiError(
|
|
5158
|
+
StatusCode::CONFLICT,
|
|
5159
|
+
"STALE_PROPOSAL: base contract does not match active contract".into(),
|
|
5160
|
+
));
|
|
5161
|
+
}
|
|
5162
|
+
if input.proposed_flow.trim().is_empty() || input.base_flow_hash.trim().is_empty() {
|
|
5163
|
+
return Err(ApiError(
|
|
5164
|
+
StatusCode::BAD_REQUEST,
|
|
5165
|
+
"proposed_flow and base_flow_hash are required".into(),
|
|
5166
|
+
));
|
|
5167
|
+
}
|
|
5168
|
+
let id = format!("p_{}", uuid::Uuid::new_v4().simple());
|
|
5169
|
+
let now = unix_seconds_i64();
|
|
5170
|
+
let mut value = json!({ "id":id, "application_id":input.application_id, "environment":input.environment,
|
|
5171
|
+
"status":"proposed", "proposal_version":1, "base_contract_version":revision.revision_number,
|
|
5172
|
+
"base_contract_hash":input.base_contract_hash, "base_flow_hash":input.base_flow_hash, "proposed_flow":input.proposed_flow,
|
|
5173
|
+
"contract_diff":input.contract_diff.unwrap_or_else(||json!([])), "source_plan":input.source_plan.unwrap_or_else(||json!({"files":[]})), "summary":input.summary, "warnings":input.warnings,
|
|
5174
|
+
"created_by":principal.key_id, "created_at":now, "expires_at":input.expires_at, "metadata":input.metadata,
|
|
5175
|
+
"parent_proposal_id":input.parent_proposal_id,
|
|
5176
|
+
"base_module_versions": revision.manifest.modules.iter().filter_map(|module| Some(json!({
|
|
5177
|
+
"id": module.get("id")?, "provider": module.get("provider")?, "version": module.get("version")?
|
|
5178
|
+
}))).collect::<Vec<_>>(),
|
|
5179
|
+
"module_versions": revision.manifest.modules.iter().filter_map(|module| Some(json!({
|
|
5180
|
+
"id": module.get("id")?, "provider": module.get("provider")?, "version": module.get("version")?
|
|
5181
|
+
}))).collect::<Vec<_>>() });
|
|
5182
|
+
redact_inspection_value(&mut value);
|
|
5183
|
+
insert_canonical(&state, PROPOSAL_COLLECTION, &id, value.clone())?;
|
|
5184
|
+
proposal_event(&state, &id, None, "proposed", &principal.key_id)?;
|
|
5185
|
+
Ok((StatusCode::CREATED, Json(value)))
|
|
5186
|
+
}
|
|
5187
|
+
async fn list_proposals(
|
|
5188
|
+
State(state): State<AppState>,
|
|
5189
|
+
Extension(principal): Extension<Principal>,
|
|
5190
|
+
Query(input): Query<ProposalScopeRequest>,
|
|
5191
|
+
) -> Result<Json<Value>, ApiError> {
|
|
5192
|
+
application_scope(
|
|
5193
|
+
&state,
|
|
5194
|
+
&principal.key_id,
|
|
5195
|
+
&input.application_id,
|
|
5196
|
+
"application:read",
|
|
5197
|
+
)?;
|
|
5198
|
+
let limit = input.limit.unwrap_or(DEFAULT_INSPECTION_LIMIT);
|
|
5199
|
+
if limit == 0 || limit > MAX_INSPECTION_LIMIT {
|
|
5200
|
+
return Err(inspection_error(StatusCode::UNPROCESSABLE_ENTITY, "invalid_pagination", format!("limit must be between 1 and {MAX_INSPECTION_LIMIT}")));
|
|
5201
|
+
}
|
|
5202
|
+
if input.status.as_ref().is_some_and(|status| !matches!(status.as_str(), "proposed"|"validated"|"previewed"|"approved"|"applied"|"rejected"|"expired")) {
|
|
5203
|
+
return Err(inspection_error(StatusCode::UNPROCESSABLE_ENTITY, "unsupported_filter", "unsupported proposal status"));
|
|
5204
|
+
}
|
|
5205
|
+
let filter_hash = format!("{}:{}:{}", input.application_id, input.environment, input.status.as_deref().unwrap_or("*"));
|
|
5206
|
+
let state_version = state.db.sequence()?;
|
|
5207
|
+
let mut after = input.cursor.as_deref().map(|cursor| parse_inspection_cursor(cursor, PROPOSAL_COLLECTION, state_version, &filter_hash)).transpose()?;
|
|
5208
|
+
let mut accepted = Vec::new();
|
|
5209
|
+
let mut exhausted = false;
|
|
5210
|
+
while accepted.len() <= limit && !exhausted {
|
|
5211
|
+
let page = state.db.list_collection_page(PROPOSAL_COLLECTION, after.as_deref(), 256)?;
|
|
5212
|
+
if page.len() < 256 { exhausted = true; }
|
|
5213
|
+
if page.is_empty() { break; }
|
|
5214
|
+
for row in page {
|
|
5215
|
+
after = Some(row.key.clone());
|
|
5216
|
+
if !proposal_scope_matches(&row.value, &input.application_id, &input.environment) { continue; }
|
|
5217
|
+
if input.status.as_ref().is_some_and(|status| row.value.get("status").and_then(Value::as_str) != Some(status)) { continue; }
|
|
5218
|
+
let mut value = row.value;
|
|
5219
|
+
redact_inspection_value(&mut value);
|
|
5220
|
+
accepted.push((row.key, value));
|
|
5221
|
+
if accepted.len() > limit { break; }
|
|
5222
|
+
}
|
|
5223
|
+
}
|
|
5224
|
+
let has_more = accepted.len() > limit;
|
|
5225
|
+
let last_key = accepted.get(limit.saturating_sub(1)).map(|entry| entry.0.clone());
|
|
5226
|
+
let proposals = accepted.into_iter().take(limit).map(|entry| entry.1).collect::<Vec<_>>();
|
|
5227
|
+
let next_cursor = if has_more { last_key.map(|key| inspection_cursor(PROPOSAL_COLLECTION, state_version, &key, &filter_hash)) } else { None };
|
|
5228
|
+
Ok(Json(json!({"proposals":proposals,"pagination":{"limit":limit,"nextCursor":next_cursor},"state_version":state_version})))
|
|
5229
|
+
}
|
|
5230
|
+
async fn get_proposal(
|
|
5231
|
+
State(state): State<AppState>,
|
|
5232
|
+
Extension(principal): Extension<Principal>,
|
|
5233
|
+
Path(id): Path<String>,
|
|
5234
|
+
Query(input): Query<ProposalScopeRequest>,
|
|
5235
|
+
) -> Result<Json<Value>, ApiError> {
|
|
5236
|
+
application_scope(
|
|
5237
|
+
&state,
|
|
5238
|
+
&principal.key_id,
|
|
5239
|
+
&input.application_id,
|
|
5240
|
+
"application:read",
|
|
5241
|
+
)?;
|
|
5242
|
+
let mut value = proposal_value(&state, &id)?;
|
|
5243
|
+
if !proposal_scope_matches(&value, &input.application_id, &input.environment) {
|
|
5244
|
+
return Err(ApiError(StatusCode::NOT_FOUND, "proposal not found".into()));
|
|
5245
|
+
}
|
|
5246
|
+
let mut value = value;
|
|
5247
|
+
redact_inspection_value(&mut value);
|
|
5248
|
+
Ok(Json(value))
|
|
5249
|
+
}
|
|
5250
|
+
async fn proposal_history(
|
|
5251
|
+
State(state): State<AppState>,
|
|
5252
|
+
Extension(principal): Extension<Principal>,
|
|
5253
|
+
Path(id): Path<String>,
|
|
5254
|
+
Query(input): Query<ProposalScopeRequest>,
|
|
5255
|
+
) -> Result<Json<Value>, ApiError> {
|
|
5256
|
+
let _ = get_proposal(
|
|
5257
|
+
State(state.clone()),
|
|
5258
|
+
Extension(principal),
|
|
5259
|
+
Path(id.clone()),
|
|
5260
|
+
Query(input),
|
|
5261
|
+
)
|
|
5262
|
+
.await?;
|
|
5263
|
+
let mut events: Vec<Value> = state
|
|
5264
|
+
.db
|
|
5265
|
+
.list_collection(PROPOSAL_EVENT_COLLECTION)?
|
|
5266
|
+
.into_iter()
|
|
5267
|
+
.map(|row| row.value)
|
|
5268
|
+
.filter(|value| value.get("proposal_id").and_then(Value::as_str) == Some(id.as_str()))
|
|
5269
|
+
.collect();
|
|
5270
|
+
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))));
|
|
5271
|
+
for event in &mut events { redact_inspection_value(event); }
|
|
5272
|
+
Ok(Json(json!({"events":events})))
|
|
5273
|
+
}
|
|
5274
|
+
fn move_proposal(
|
|
5275
|
+
state: &AppState,
|
|
5276
|
+
id: &str,
|
|
5277
|
+
mut value: Value,
|
|
5278
|
+
to: &str,
|
|
5279
|
+
actor: &str,
|
|
5280
|
+
) -> Result<Value, ApiError> {
|
|
5281
|
+
let from = value
|
|
5282
|
+
.get("status")
|
|
5283
|
+
.and_then(Value::as_str)
|
|
5284
|
+
.unwrap_or("")
|
|
5285
|
+
.to_string();
|
|
5286
|
+
let allowed = matches!(
|
|
5287
|
+
(from.as_str(), to),
|
|
5288
|
+
("proposed", "validated")
|
|
5289
|
+
| ("proposed", "rejected")
|
|
5290
|
+
| ("validated", "previewed")
|
|
5291
|
+
| ("validated", "rejected")
|
|
5292
|
+
| ("previewed", "approved")
|
|
5293
|
+
| ("previewed", "rejected")
|
|
5294
|
+
| ("approved", "applied")
|
|
5295
|
+
| ("approved", "expired")
|
|
5296
|
+
);
|
|
5297
|
+
if !allowed {
|
|
5298
|
+
return Err(ApiError(
|
|
5299
|
+
StatusCode::CONFLICT,
|
|
5300
|
+
format!("INVALID_PROPOSAL_TRANSITION: {from} -> {to}"),
|
|
5301
|
+
));
|
|
5302
|
+
}
|
|
5303
|
+
value["status"] = json!(to);
|
|
5304
|
+
if to == "approved" {
|
|
5305
|
+
value["approved_by"] = json!(actor);
|
|
5306
|
+
value["approved_at"] = json!(unix_seconds_i64());
|
|
5307
|
+
}
|
|
5308
|
+
if to == "applied" {
|
|
5309
|
+
value["applied_at"] = json!(unix_seconds_i64());
|
|
5310
|
+
}
|
|
5311
|
+
if to == "rejected" {
|
|
5312
|
+
value["rejected_by"] = json!(actor);
|
|
5313
|
+
value["rejected_at"] = json!(unix_seconds_i64());
|
|
5314
|
+
}
|
|
5315
|
+
let _ = put_canonical(state, PROPOSAL_COLLECTION, id, value.clone())?;
|
|
5316
|
+
proposal_event(state, id, Some(&from), to, actor)?;
|
|
5317
|
+
Ok(value)
|
|
5318
|
+
}
|
|
5319
|
+
|
|
5320
|
+
fn proposal_is_expired(value: &Value) -> bool {
|
|
5321
|
+
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())
|
|
5322
|
+
}
|
|
5323
|
+
|
|
5324
|
+
fn proposal_readiness_value(
|
|
5325
|
+
state: &AppState,
|
|
5326
|
+
tenant: &str,
|
|
5327
|
+
value: &Value,
|
|
5328
|
+
) -> Result<Value, ApiError> {
|
|
5329
|
+
let application_id = value.get("application_id").and_then(Value::as_str).unwrap_or("");
|
|
5330
|
+
let environment = value.get("environment").and_then(Value::as_str).unwrap_or("production");
|
|
5331
|
+
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"))?;
|
|
5332
|
+
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"))?;
|
|
5333
|
+
let current_contract_hash = revision.manifest.metadata.labels.get("feltdb.contract_hash").unwrap_or(&revision.manifest_hash).clone();
|
|
5334
|
+
let current_flow_hash = revision.manifest.metadata.labels.get("feltdb.flow_hash").cloned();
|
|
5335
|
+
let proposal_contract_hash = value.get("base_contract_hash").and_then(Value::as_str).unwrap_or("");
|
|
5336
|
+
let proposal_flow_hash = value.get("base_flow_hash").and_then(Value::as_str).unwrap_or("");
|
|
5337
|
+
let current_modules = revision.manifest.modules.iter().filter_map(|module| Some(json!({"id":module.get("id")?,"provider":module.get("provider")?,"version":module.get("version")?}))).collect::<Vec<_>>();
|
|
5338
|
+
let contract_changed = current_contract_hash != proposal_contract_hash;
|
|
5339
|
+
let flow_changed = current_flow_hash.as_deref().is_some_and(|hash| hash != proposal_flow_hash);
|
|
5340
|
+
let modules_changed = value.get("base_module_versions").and_then(Value::as_array).is_some_and(|modules| modules != ¤t_modules);
|
|
5341
|
+
let expired = proposal_is_expired(value);
|
|
5342
|
+
let mut blockers = Vec::new();
|
|
5343
|
+
if contract_changed { blockers.push("contract_changed"); }
|
|
5344
|
+
if flow_changed { blockers.push("flow_changed"); }
|
|
5345
|
+
if modules_changed { blockers.push("module_versions_changed"); }
|
|
5346
|
+
if expired { blockers.push("expired"); }
|
|
5347
|
+
let stale = contract_changed || flow_changed || modules_changed;
|
|
5348
|
+
Ok(json!({
|
|
5349
|
+
"status":value.get("status"), "readiness":if blockers.is_empty(){"ready"}else{"blocked"},
|
|
5350
|
+
"blockers":blockers, "notes":[], "current_contract_hash":current_contract_hash,
|
|
5351
|
+
"proposal_contract_hash":proposal_contract_hash, "current_flow_hash":current_flow_hash,
|
|
5352
|
+
"proposal_flow_hash":proposal_flow_hash, "stale":stale, "expires_at":value.get("expires_at")
|
|
5353
|
+
}))
|
|
5354
|
+
}
|
|
5355
|
+
|
|
5356
|
+
async fn proposal_status(
|
|
5357
|
+
State(state): State<AppState>,
|
|
5358
|
+
Extension(principal): Extension<Principal>,
|
|
5359
|
+
Path(id): Path<String>,
|
|
5360
|
+
Query(input): Query<ProposalScopeRequest>,
|
|
5361
|
+
) -> Result<Json<Value>, ApiError> {
|
|
5362
|
+
let tenant = application_scope(&state, &principal.key_id, &input.application_id, "application:read")?;
|
|
5363
|
+
let mut value = proposal_value(&state, &id)?;
|
|
5364
|
+
if !proposal_scope_matches(&value, &input.application_id, &input.environment) {
|
|
5365
|
+
return Err(inspection_error(StatusCode::NOT_FOUND, "proposal_not_found", "proposal not found"));
|
|
5366
|
+
}
|
|
5367
|
+
Ok(Json(proposal_readiness_value(&state, &tenant, &value)?))
|
|
5368
|
+
}
|
|
5369
|
+
async fn validate_proposal(
|
|
5370
|
+
State(state): State<AppState>,
|
|
5371
|
+
Extension(principal): Extension<Principal>,
|
|
5372
|
+
Path(id): Path<String>,
|
|
5373
|
+
Json(input): Json<ProposalActorRequest>,
|
|
5374
|
+
) -> Result<Json<Value>, ApiError> {
|
|
5375
|
+
let tenant = application_scope(
|
|
5376
|
+
&state,
|
|
5377
|
+
&principal.key_id,
|
|
5378
|
+
&input.application_id,
|
|
5379
|
+
"application:write",
|
|
5380
|
+
)?;
|
|
5381
|
+
let mut value = proposal_value(&state, &id)?;
|
|
5382
|
+
if !proposal_scope_matches(&value, &input.application_id, &input.environment) {
|
|
5383
|
+
return Err(ApiError(StatusCode::NOT_FOUND, "proposal not found".into()));
|
|
5384
|
+
}
|
|
5385
|
+
if value.get("status").and_then(Value::as_str) == Some("validated") {
|
|
5386
|
+
redact_inspection_value(&mut value);
|
|
5387
|
+
return Ok(Json(value));
|
|
5388
|
+
}
|
|
5389
|
+
let revision_id = state
|
|
5390
|
+
.applications
|
|
5391
|
+
.pointers(&input.application_id)
|
|
5392
|
+
.get(&input.environment)
|
|
5393
|
+
.cloned()
|
|
5394
|
+
.ok_or(ApiError(
|
|
5395
|
+
StatusCode::NOT_FOUND,
|
|
5396
|
+
"active contract not found".into(),
|
|
5397
|
+
))?;
|
|
5398
|
+
let revision = state
|
|
5399
|
+
.applications
|
|
5400
|
+
.revision(&tenant, &input.application_id, &revision_id)
|
|
5401
|
+
.ok_or(ApiError(
|
|
5402
|
+
StatusCode::NOT_FOUND,
|
|
5403
|
+
"application revision not found".into(),
|
|
5404
|
+
))?;
|
|
5405
|
+
let hash = revision
|
|
5406
|
+
.manifest
|
|
5407
|
+
.metadata
|
|
5408
|
+
.labels
|
|
5409
|
+
.get("feltdb.contract_hash")
|
|
5410
|
+
.unwrap_or(&revision.manifest_hash);
|
|
5411
|
+
if value.get("base_contract_hash").and_then(Value::as_str) != Some(hash) {
|
|
5412
|
+
return Err(ApiError(StatusCode::CONFLICT, "STALE_PROPOSAL".into()));
|
|
5413
|
+
}
|
|
5414
|
+
if let Some(manifest) = input.proposed_manifest {
|
|
5415
|
+
let report = validate_manifest(
|
|
5416
|
+
&manifest,
|
|
5417
|
+
&tenant,
|
|
5418
|
+
&input.application_id,
|
|
5419
|
+
Some(&revision.manifest),
|
|
5420
|
+
);
|
|
5421
|
+
if !report.valid {
|
|
5422
|
+
return Err(ApiError::structured(
|
|
5423
|
+
StatusCode::UNPROCESSABLE_ENTITY,
|
|
5424
|
+
json!({"code":"INVALID_PROPOSAL","validation":report}),
|
|
5425
|
+
));
|
|
5426
|
+
}
|
|
5427
|
+
value["module_versions"] = json!(manifest.modules.iter().filter_map(|module| Some(json!({
|
|
5428
|
+
"id": module.get("id")?, "provider": module.get("provider")?, "version": module.get("version")?
|
|
5429
|
+
}))).collect::<Vec<_>>());
|
|
5430
|
+
let module_ids = manifest
|
|
5431
|
+
.modules
|
|
5432
|
+
.iter()
|
|
5433
|
+
.filter_map(|module| module.get("id").and_then(Value::as_str))
|
|
5434
|
+
.collect::<Vec<_>>();
|
|
5435
|
+
let module_models = manifest
|
|
5436
|
+
.collections
|
|
5437
|
+
.iter()
|
|
5438
|
+
.flat_map(|collection| collection.fields.iter())
|
|
5439
|
+
.filter_map(|field| field.reference.as_deref())
|
|
5440
|
+
.filter(|reference| {
|
|
5441
|
+
module_ids
|
|
5442
|
+
.iter()
|
|
5443
|
+
.any(|id| reference.starts_with(&format!("{id}.")))
|
|
5444
|
+
})
|
|
5445
|
+
.collect::<Vec<_>>();
|
|
5446
|
+
let relationships = manifest
|
|
5447
|
+
.collections
|
|
5448
|
+
.iter()
|
|
5449
|
+
.flat_map(|collection| {
|
|
5450
|
+
collection.fields.iter().filter_map(|field| {
|
|
5451
|
+
field.reference.as_ref().map(|reference| {
|
|
5452
|
+
format!("{}.{} -> {}", collection.name, field.name, reference)
|
|
5453
|
+
})
|
|
5454
|
+
})
|
|
5455
|
+
})
|
|
5456
|
+
.collect::<Vec<_>>();
|
|
5457
|
+
let ownership = manifest
|
|
5458
|
+
.modules
|
|
5459
|
+
.iter()
|
|
5460
|
+
.flat_map(|module| {
|
|
5461
|
+
let id = module.get("id").and_then(Value::as_str).unwrap_or("");
|
|
5462
|
+
module
|
|
5463
|
+
.get("models")
|
|
5464
|
+
.and_then(Value::as_array)
|
|
5465
|
+
.into_iter()
|
|
5466
|
+
.flatten()
|
|
5467
|
+
.filter_map(move |model| {
|
|
5468
|
+
Some((
|
|
5469
|
+
format!("{}.{}", id, model.get("name")?.as_str()?),
|
|
5470
|
+
Value::String(model.get("ownership")?.as_str()?.to_string()),
|
|
5471
|
+
))
|
|
5472
|
+
})
|
|
5473
|
+
})
|
|
5474
|
+
.collect::<serde_json::Map<String, Value>>();
|
|
5475
|
+
value["module_impact"] = json!({
|
|
5476
|
+
"modules":module_ids, "models":module_models, "relationships":relationships,
|
|
5477
|
+
"events":manifest.triggers.iter().filter(|trigger| module_ids.iter().any(|id| trigger.event.starts_with(&format!("{id}.")))).map(|trigger|trigger.event.clone()).collect::<Vec<_>>(),
|
|
5478
|
+
"workflows":manifest.workflows.iter().map(|workflow|workflow.name.clone()).collect::<Vec<_>>(),
|
|
5479
|
+
"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<_>>(),
|
|
5480
|
+
"authorization":manifest.policies.iter().map(|policy|policy.name.clone()).collect::<Vec<_>>(),
|
|
5481
|
+
"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<_>>(),
|
|
5482
|
+
"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<_>>(),
|
|
5483
|
+
"ownership":ownership,
|
|
5484
|
+
"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<_>>()
|
|
5485
|
+
});
|
|
5486
|
+
let proposed = ApplicationRevision {
|
|
5487
|
+
revision_id: format!("proposal://{id}"),
|
|
5488
|
+
application_id: input.application_id.clone(),
|
|
5489
|
+
tenant_id: tenant,
|
|
5490
|
+
revision_number: revision.revision_number + 1,
|
|
5491
|
+
parent_revision_id: Some(revision.revision_id.clone()),
|
|
5492
|
+
manifest_hash: String::new(),
|
|
5493
|
+
manifest,
|
|
5494
|
+
created_by: principal.key_id.clone(),
|
|
5495
|
+
created_at: unix_seconds_i64() as u64,
|
|
5496
|
+
status: RevisionStatus::Committed,
|
|
5497
|
+
};
|
|
5498
|
+
value["contract_diff"] =
|
|
5499
|
+
serde_json::to_value(diff_manifests(Some(&revision), &proposed).changes)
|
|
5500
|
+
.map_err(|e| ApiError(StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
|
5501
|
+
value["proposed_contract"] = serde_json::to_value(&proposed.manifest)
|
|
5502
|
+
.map_err(|e| ApiError(StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
|
5503
|
+
}
|
|
5504
|
+
value["source_plan"] = json!({"files":[{"path":"feltdb.flow","operation":"modify"},{"path":"src/feltdb/generated-contract.ts","operation":"modify"}]});
|
|
5505
|
+
Ok(Json(move_proposal(
|
|
5506
|
+
&state,
|
|
5507
|
+
&id,
|
|
5508
|
+
value,
|
|
5509
|
+
"validated",
|
|
5510
|
+
&principal.key_id,
|
|
5511
|
+
)?))
|
|
5512
|
+
}
|
|
5513
|
+
async fn preview_proposal(
|
|
5514
|
+
State(state): State<AppState>,
|
|
5515
|
+
Extension(principal): Extension<Principal>,
|
|
5516
|
+
Path(id): Path<String>,
|
|
5517
|
+
Json(input): Json<ProposalActorRequest>,
|
|
5518
|
+
) -> Result<Json<Value>, ApiError> {
|
|
5519
|
+
let tenant = application_scope(
|
|
5520
|
+
&state,
|
|
5521
|
+
&principal.key_id,
|
|
5522
|
+
&input.application_id,
|
|
5523
|
+
"application:write",
|
|
5524
|
+
)?;
|
|
5525
|
+
let mut value = proposal_value(&state, &id)?;
|
|
5526
|
+
if !proposal_scope_matches(&value, &input.application_id, &input.environment) {
|
|
5527
|
+
return Err(ApiError(StatusCode::NOT_FOUND, "proposal not found".into()));
|
|
5528
|
+
}
|
|
5529
|
+
let replacing_expired_preview = value.get("status").and_then(Value::as_str) == Some("previewed");
|
|
5530
|
+
if replacing_expired_preview {
|
|
5531
|
+
if let Some(preview_id) = value.get("preview_id").and_then(Value::as_str) {
|
|
5532
|
+
let mut artifact = preview_artifact(&state, preview_id)?;
|
|
5533
|
+
if preview_available(&artifact) {
|
|
5534
|
+
let mut proposal = value.clone();
|
|
5535
|
+
redact_inspection_value(&mut proposal);
|
|
5536
|
+
redact_inspection_value(&mut artifact);
|
|
5537
|
+
if let Some(object) = artifact.as_object_mut() { object.remove("namespace"); }
|
|
5538
|
+
artifact["proposal"] = proposal;
|
|
5539
|
+
return Ok(Json(artifact));
|
|
5540
|
+
}
|
|
5541
|
+
}
|
|
5542
|
+
}
|
|
5543
|
+
if !replacing_expired_preview && value.get("status").and_then(Value::as_str) != Some("validated") {
|
|
5544
|
+
return Err(inspection_error(StatusCode::CONFLICT, "invalid_proposal_transition", "only a validated proposal can be previewed"));
|
|
5545
|
+
}
|
|
5546
|
+
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()))?;
|
|
5547
|
+
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"))?;
|
|
5548
|
+
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"))?;
|
|
5549
|
+
let readiness = proposal_readiness_value(&state, &tenant, &value)?;
|
|
5550
|
+
if readiness.get("readiness").and_then(Value::as_str) != Some("ready") {
|
|
5551
|
+
return Err(ApiError::structured(StatusCode::CONFLICT, json!({"code":"preview_not_ready","message":"proposal cannot be previewed","readiness":readiness})));
|
|
5552
|
+
}
|
|
5553
|
+
let preview_id = format!("pv_{}", uuid::Uuid::new_v4().simple());
|
|
5554
|
+
let preview_environment = format!("preview/proposal/{preview_id}");
|
|
5555
|
+
let proposed_manifest_hash = manifest_hash(&proposed_manifest).map_err(|error| inspection_error(StatusCode::UNPROCESSABLE_ENTITY, "preview_contract_invalid", error))?;
|
|
5556
|
+
let proposed_revision = ApplicationRevision { revision_id:format!("proposal://{id}"), application_id:input.application_id.clone(), tenant_id:tenant,
|
|
5557
|
+
revision_number:value.get("base_contract_version").and_then(Value::as_u64).unwrap_or(current_revision.revision_number)+1,
|
|
5558
|
+
parent_revision_id:Some(current_revision.revision_id.clone()), manifest_hash:proposed_manifest_hash, manifest:proposed_manifest,
|
|
5559
|
+
created_by:principal.key_id.clone(), created_at:unix_seconds_i64() as u64, status:RevisionStatus::Committed };
|
|
5560
|
+
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})))?;
|
|
5561
|
+
let current_contract = proposal_contract_snapshot(¤t_revision, &input.environment);
|
|
5562
|
+
let mut proposed_contract = proposal_contract_snapshot(&proposed_revision, &input.environment);
|
|
5563
|
+
proposed_contract["contract_hash"] = json!(proposal_hash(&proposed_contract));
|
|
5564
|
+
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())));
|
|
5565
|
+
let namespace = runtime.environment.state_namespace.clone();
|
|
5566
|
+
let mut seeded_records = Vec::new();
|
|
5567
|
+
for collection in &proposed_revision.manifest.collections {
|
|
5568
|
+
if collection.name.starts_with("_feltdb") { continue; }
|
|
5569
|
+
let record_id = format!("preview_{}", collection.name.to_ascii_lowercase());
|
|
5570
|
+
let record = json!({"id":record_id,"_preview_data":true,"_preview_source":"synthetic","label":format!("Synthetic {} preview",collection.name)});
|
|
5571
|
+
state.db.capability(&format!("{}:{}", namespace, collection.name)).insert_with_key(&record_id, record)?;
|
|
5572
|
+
seeded_records.push(json!({"collection":collection.name,"id":record_id,"preview_data":true}));
|
|
5573
|
+
}
|
|
5574
|
+
let now = unix_seconds_i64();
|
|
5575
|
+
let mut artifact = json!({
|
|
5576
|
+
"preview_id":preview_id,"proposal_id":id,"application_id":input.application_id,"environment":input.environment,
|
|
5577
|
+
"proposal_hash":proposal_hash(&value),"proposal_contract_hash":value.get("base_contract_hash"),"proposal_flow_hash":value.get("base_flow_hash"),
|
|
5578
|
+
"module_versions":value.get("module_versions"),"created_at":now,"expires_at":now+preview_ttl_seconds(),"status":"ready",
|
|
5579
|
+
"namespace":namespace,"current":current_contract,"proposed":proposed_contract,"current_contract":current_contract,
|
|
5580
|
+
"proposed_contract":proposed_contract,"semantic_diff":value.get("contract_diff"),"state_flow":value.pointer("/module_impact/state_flow"),
|
|
5581
|
+
"capabilities":runtime.capabilities,"workflows":runtime.workflows,"authorization":runtime.policies,
|
|
5582
|
+
"configuration":value.pointer("/module_impact/configuration"),"secrets":value.pointer("/module_impact/secrets"),
|
|
5583
|
+
"modules":value.pointer("/module_impact/modules"),"ownership":value.pointer("/module_impact/ownership"),
|
|
5584
|
+
"relationships":value.pointer("/module_impact/relationships"),"events":value.pointer("/module_impact/events"),
|
|
5585
|
+
"seeded_records":seeded_records,"data_classification":"synthetic_preview","external_side_effects":"simulated","readiness":readiness
|
|
5586
|
+
});
|
|
5587
|
+
redact_inspection_value(&mut artifact);
|
|
5588
|
+
insert_canonical(&state, PROPOSAL_PREVIEW_COLLECTION, artifact.get("preview_id").and_then(Value::as_str).unwrap_or(""), artifact.clone())?;
|
|
5589
|
+
value["preview_id"] = artifact.get("preview_id").cloned().unwrap_or(Value::Null);
|
|
5590
|
+
value["preview_expires_at"] = artifact.get("expires_at").cloned().unwrap_or(Value::Null);
|
|
5591
|
+
let mut stored_proposal = if replacing_expired_preview {
|
|
5592
|
+
let _ = put_canonical(&state, PROPOSAL_COLLECTION, &id, value.clone())?;
|
|
5593
|
+
value
|
|
5594
|
+
} else {
|
|
5595
|
+
move_proposal(&state, &id, value, "previewed", &principal.key_id)?
|
|
5596
|
+
};
|
|
5597
|
+
redact_inspection_value(&mut stored_proposal);
|
|
5598
|
+
if let Some(object) = artifact.as_object_mut() { object.remove("namespace"); }
|
|
5599
|
+
artifact["proposal"] = stored_proposal;
|
|
5600
|
+
Ok(Json(artifact))
|
|
5601
|
+
}
|
|
5602
|
+
|
|
5603
|
+
async fn get_proposal_preview(
|
|
5604
|
+
State(state): State<AppState>, Extension(principal): Extension<Principal>, Path(id): Path<String>,
|
|
5605
|
+
Query(input): Query<ProposalScopeRequest>,
|
|
5606
|
+
) -> Result<Json<Value>, ApiError> {
|
|
5607
|
+
let tenant = application_scope(&state, &principal.key_id, &input.application_id, "application:read")?;
|
|
5608
|
+
let mut artifact = preview_artifact(&state, &id)?;
|
|
5609
|
+
if !proposal_scope_matches(&artifact, &input.application_id, &input.environment) {
|
|
5610
|
+
return Err(inspection_error(StatusCode::NOT_FOUND, "preview_not_found", "preview not found"));
|
|
5611
|
+
}
|
|
5612
|
+
if !preview_available(&artifact) {
|
|
5613
|
+
return Err(inspection_error(StatusCode::GONE, "preview_expired", "preview artifact has expired"));
|
|
5614
|
+
}
|
|
5615
|
+
let proposal = proposal_value(&state, artifact.get("proposal_id").and_then(Value::as_str).unwrap_or(""))?;
|
|
5616
|
+
if proposal.get("preview_id") != artifact.get("preview_id") {
|
|
5617
|
+
return Err(inspection_error(StatusCode::CONFLICT, "preview_invalidated", "preview has been replaced"));
|
|
5618
|
+
}
|
|
5619
|
+
if proposal_readiness_value(&state, &tenant, &proposal)?.get("stale").and_then(Value::as_bool) == Some(true) {
|
|
5620
|
+
return Err(inspection_error(StatusCode::CONFLICT, "preview_invalidated", "the application authority changed after preview creation"));
|
|
5621
|
+
}
|
|
5622
|
+
redact_inspection_value(&mut artifact);
|
|
5623
|
+
if let Some(object) = artifact.as_object_mut() { object.remove("namespace"); }
|
|
5624
|
+
Ok(Json(artifact))
|
|
5625
|
+
}
|
|
5626
|
+
|
|
5627
|
+
async fn inspect_proposal_preview_data(
|
|
5628
|
+
State(state): State<AppState>, Extension(principal): Extension<Principal>, Path((id, collection)): Path<(String, String)>,
|
|
5629
|
+
Query(input): Query<ProposalScopeRequest>,
|
|
5630
|
+
) -> Result<Json<Value>, ApiError> {
|
|
5631
|
+
application_scope(&state, &principal.key_id, &input.application_id, "application:read")?;
|
|
5632
|
+
let artifact = preview_artifact(&state, &id)?;
|
|
5633
|
+
if !proposal_scope_matches(&artifact, &input.application_id, &input.environment) {
|
|
5634
|
+
return Err(inspection_error(StatusCode::NOT_FOUND, "preview_not_found", "preview not found"));
|
|
5635
|
+
}
|
|
5636
|
+
if !preview_available(&artifact) { return Err(inspection_error(StatusCode::GONE, "preview_expired", "preview artifact has expired")); }
|
|
5637
|
+
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))) {
|
|
5638
|
+
return Err(inspection_error(StatusCode::NOT_FOUND, "collection_not_found", "collection is not part of the proposed contract"));
|
|
5639
|
+
}
|
|
5640
|
+
let limit = input.limit.unwrap_or(DEFAULT_INSPECTION_LIMIT);
|
|
5641
|
+
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}"))); }
|
|
5642
|
+
let state_version = state.db.sequence()?;
|
|
5643
|
+
let filter_hash = format!("{id}:{collection}");
|
|
5644
|
+
let after = input.cursor.as_deref().map(|cursor| parse_inspection_cursor(cursor, &filter_hash, state_version, "preview-data")).transpose()?;
|
|
5645
|
+
let capability = format!("{}:{}", artifact.get("namespace").and_then(Value::as_str).unwrap_or(""), collection);
|
|
5646
|
+
let mut rows = state.db.list_collection_page(&capability, after.as_deref(), limit.saturating_add(1))?;
|
|
5647
|
+
let has_more = rows.len() > limit;
|
|
5648
|
+
rows.truncate(limit);
|
|
5649
|
+
let last_key = rows.last().map(|row| row.key.clone());
|
|
5650
|
+
let mut records = rows.into_iter().map(|row| row.value).collect::<Vec<_>>();
|
|
5651
|
+
for record in &mut records { redact_inspection_value(record); }
|
|
5652
|
+
let next_cursor = if has_more { last_key.map(|key| inspection_cursor(&filter_hash, state_version, &key, "preview-data")) } else { None };
|
|
5653
|
+
Ok(Json(json!({"preview_id":id,"collection":collection,"records":records,"data_classification":"synthetic_preview","pagination":{"limit":limit,"nextCursor":next_cursor}})))
|
|
5654
|
+
}
|
|
5655
|
+
|
|
5656
|
+
async fn simulate_proposal_preview(
|
|
5657
|
+
State(state): State<AppState>, Extension(principal): Extension<Principal>, Path(id): Path<String>,
|
|
5658
|
+
Query(input): Query<ProposalScopeRequest>, Json(simulation): Json<PreviewSimulationRequest>,
|
|
5659
|
+
) -> Result<Json<Value>, ApiError> {
|
|
5660
|
+
application_scope(&state, &principal.key_id, &input.application_id, "application:write")?;
|
|
5661
|
+
let artifact = preview_artifact(&state, &id)?;
|
|
5662
|
+
if !proposal_scope_matches(&artifact, &input.application_id, &input.environment) { return Err(inspection_error(StatusCode::NOT_FOUND, "preview_not_found", "preview not found")); }
|
|
5663
|
+
if !preview_available(&artifact) { return Err(inspection_error(StatusCode::GONE, "preview_expired", "preview artifact has expired")); }
|
|
5664
|
+
let known = artifact.get("events").and_then(Value::as_array).is_some_and(|events| events.iter().any(|event| event.as_str()==Some(&simulation.event)));
|
|
5665
|
+
if !known { return Err(inspection_error(StatusCode::UNPROCESSABLE_ENTITY, "unsupported_preview_event", "event is not part of the proposal")); }
|
|
5666
|
+
let state_flow = artifact.get("state_flow").cloned().unwrap_or_else(||json!([]));
|
|
5667
|
+
Ok(Json(json!({"preview_id":id,"event":simulation.event,"status":"simulated","external_side_effects":"none","trace":state_flow,"writes":"isolated_preview_namespace_only"})))
|
|
5668
|
+
}
|
|
5669
|
+
async fn transition_proposal(
|
|
5670
|
+
State(state): State<AppState>,
|
|
5671
|
+
Extension(principal): Extension<Principal>,
|
|
5672
|
+
headers: HeaderMap,
|
|
5673
|
+
Path(id): Path<String>,
|
|
5674
|
+
Json(input): Json<ProposalStatusRequest>,
|
|
5675
|
+
) -> Result<Json<Value>, ApiError> {
|
|
5676
|
+
let tenant = application_scope(
|
|
5677
|
+
&state,
|
|
5678
|
+
&principal.key_id,
|
|
5679
|
+
&input.application_id,
|
|
5680
|
+
"application:write",
|
|
5681
|
+
)?;
|
|
5682
|
+
let mut value = proposal_value(&state, &id)?;
|
|
5683
|
+
if !proposal_scope_matches(&value, &input.application_id, &input.environment) {
|
|
5684
|
+
return Err(ApiError(StatusCode::NOT_FOUND, "proposal not found".into()));
|
|
5685
|
+
}
|
|
5686
|
+
if !matches!(
|
|
5687
|
+
input.status.as_str(),
|
|
5688
|
+
"approved" | "rejected" | "expired" | "applied"
|
|
5689
|
+
) {
|
|
5690
|
+
return Err(ApiError(
|
|
5691
|
+
StatusCode::BAD_REQUEST,
|
|
5692
|
+
"unsupported proposal status".into(),
|
|
5693
|
+
));
|
|
5694
|
+
}
|
|
5695
|
+
if proposal_is_expired(&value) && input.status != "expired" {
|
|
5696
|
+
return Err(inspection_error(StatusCode::CONFLICT, "proposal_expired", "proposal has expired"));
|
|
5697
|
+
}
|
|
5698
|
+
if input.status == "approved" {
|
|
5699
|
+
let readiness = proposal_readiness_value(&state, &tenant, &value)?;
|
|
5700
|
+
if readiness.get("readiness").and_then(Value::as_str) != Some("ready") {
|
|
5701
|
+
return Err(ApiError::structured(StatusCode::CONFLICT, json!({"code":"stale_proposal","message":"proposal is no longer applicable","readiness":readiness})));
|
|
5702
|
+
}
|
|
5703
|
+
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"))?;
|
|
5704
|
+
let artifact = preview_artifact(&state, preview_id)?;
|
|
5705
|
+
let bound = preview_available(&artifact)
|
|
5706
|
+
&& artifact.get("proposal_id").and_then(Value::as_str) == Some(id.as_str())
|
|
5707
|
+
&& artifact.get("proposal_contract_hash") == value.get("base_contract_hash")
|
|
5708
|
+
&& artifact.get("proposal_flow_hash") == value.get("base_flow_hash")
|
|
5709
|
+
&& artifact.get("module_versions") == value.get("module_versions");
|
|
5710
|
+
if !bound { return Err(inspection_error(StatusCode::CONFLICT, "preview_invalid", "approval requires a current preview bound to this proposal")); }
|
|
5711
|
+
}
|
|
5712
|
+
if input.status == "applied" {
|
|
5713
|
+
let configured = std::env::var("FELTDB_PROPOSAL_AUTHORITY_TOKEN").ok();
|
|
5714
|
+
let supplied = headers.get("FeltDB-Proposal-Authority").and_then(|value| value.to_str().ok());
|
|
5715
|
+
let repository_apply = headers.get("FeltDB-Repository-Apply").and_then(|value| value.to_str().ok()) == Some("1");
|
|
5716
|
+
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());
|
|
5717
|
+
if !repository_apply || !authorized {
|
|
5718
|
+
return Err(inspection_error(StatusCode::FORBIDDEN, "repository_authority_required", "valid repository application authority is required"));
|
|
5719
|
+
}
|
|
5720
|
+
}
|
|
5721
|
+
if input.status == "approved"
|
|
5722
|
+
&& value
|
|
5723
|
+
.get("warnings")
|
|
5724
|
+
.and_then(Value::as_array)
|
|
5725
|
+
.map(|items| {
|
|
5726
|
+
items
|
|
5727
|
+
.iter()
|
|
5728
|
+
.any(|item| item.as_str().unwrap_or("").starts_with("Authorization "))
|
|
5729
|
+
})
|
|
5730
|
+
.unwrap_or(false)
|
|
5731
|
+
&& !input.approve_authorization
|
|
5732
|
+
{
|
|
5733
|
+
return Err(ApiError(
|
|
5734
|
+
StatusCode::FORBIDDEN,
|
|
5735
|
+
"AUTHORIZATION_APPROVAL_REQUIRED".into(),
|
|
5736
|
+
));
|
|
5737
|
+
}
|
|
5738
|
+
if input.status == "rejected" {
|
|
5739
|
+
if let Some(reason) = input.reason { value["rejection_reason"] = json!(reason); }
|
|
5740
|
+
}
|
|
5741
|
+
let mut moved = move_proposal(
|
|
5742
|
+
&state,
|
|
5743
|
+
&id,
|
|
5744
|
+
value,
|
|
5745
|
+
&input.status,
|
|
5746
|
+
&principal.key_id,
|
|
5747
|
+
)?;
|
|
5748
|
+
redact_inspection_value(&mut moved);
|
|
5749
|
+
Ok(Json(moved))
|
|
5750
|
+
}
|
|
5751
|
+
|
|
5752
|
+
async fn approve_proposal(
|
|
5753
|
+
state: State<AppState>, principal: Extension<Principal>, headers: HeaderMap,
|
|
5754
|
+
path: Path<String>, Json(input): Json<ProposalLifecycleRequest>,
|
|
5755
|
+
) -> Result<Json<Value>, ApiError> {
|
|
5756
|
+
transition_proposal(state, principal, headers, path, Json(ProposalStatusRequest {
|
|
5757
|
+
application_id: input.application_id, environment: input.environment, status: "approved".into(),
|
|
5758
|
+
approve_authorization: input.approve_authorization, reason: input.reason,
|
|
5759
|
+
})).await
|
|
5760
|
+
}
|
|
5761
|
+
|
|
5762
|
+
async fn reject_proposal(
|
|
5763
|
+
state: State<AppState>, principal: Extension<Principal>, headers: HeaderMap,
|
|
5764
|
+
path: Path<String>, Json(input): Json<ProposalLifecycleRequest>,
|
|
5765
|
+
) -> Result<Json<Value>, ApiError> {
|
|
5766
|
+
transition_proposal(state, principal, headers, path, Json(ProposalStatusRequest {
|
|
5767
|
+
application_id: input.application_id, environment: input.environment, status: "rejected".into(),
|
|
5768
|
+
approve_authorization: false, reason: input.reason,
|
|
5769
|
+
})).await
|
|
5770
|
+
}
|
|
5771
|
+
fn auth_response(
|
|
5772
|
+
identity: feltdb_server::identity::Identity,
|
|
5773
|
+
session: feltdb_server::identity::IdentitySession,
|
|
5774
|
+
token: String,
|
|
5775
|
+
) -> Json<Value> {
|
|
5776
|
+
Json(json!({
|
|
5777
|
+
"access_token": token,
|
|
5778
|
+
"token_type": "Bearer",
|
|
5779
|
+
"expires_at": session.expires_at,
|
|
5780
|
+
"actor": { "user_id": identity.subject_id, "identity_id": identity.identity_id, "email": identity.email, "display_name": identity.display_name },
|
|
5781
|
+
"session": { "session_id": session.session_id, "issued_at": session.issued_at, "expires_at": session.expires_at }
|
|
5782
|
+
}))
|
|
5783
|
+
}
|
|
5784
|
+
async fn auth_sign_up(
|
|
5785
|
+
State(state): State<AppState>,
|
|
5786
|
+
Json(input): Json<SignUpRequest>,
|
|
5787
|
+
) -> Result<(StatusCode, Json<Value>), ApiError> {
|
|
5788
|
+
if state
|
|
5789
|
+
.tenancy
|
|
5790
|
+
.application_tenant(&input.application_id)
|
|
5791
|
+
.is_none()
|
|
5792
|
+
{
|
|
5793
|
+
return Err(ApiError(
|
|
5794
|
+
StatusCode::NOT_FOUND,
|
|
5795
|
+
"application not found".into(),
|
|
5796
|
+
));
|
|
4350
5797
|
}
|
|
4351
|
-
|
|
4352
|
-
|
|
4353
|
-
|
|
4354
|
-
|
|
4355
|
-
|
|
4356
|
-
|
|
4357
|
-
|
|
4358
|
-
|
|
4359
|
-
|
|
4360
|
-
|
|
4361
|
-
|
|
4362
|
-
|
|
4363
|
-
)
|
|
4364
|
-
|
|
4365
|
-
|
|
4366
|
-
.
|
|
4367
|
-
|
|
4368
|
-
|
|
4369
|
-
let inventory = runtime_inventory(state, actor, application_id, &revision.manifest)?;
|
|
4370
|
-
resolve_runtime(&revision, environment, &inventory).map_err(|report| {
|
|
4371
|
-
ApiError(
|
|
4372
|
-
StatusCode::CONFLICT,
|
|
4373
|
-
serde_json::to_string(&report).unwrap_or_else(|_| "runtime validation failed".into()),
|
|
5798
|
+
let (identity, session, token) = state
|
|
5799
|
+
.identities
|
|
5800
|
+
.sign_up(&input.email, &input.password, input.display_name)
|
|
5801
|
+
.map_err(|error| {
|
|
5802
|
+
ApiError(
|
|
5803
|
+
if error == "identity already exists" {
|
|
5804
|
+
StatusCode::CONFLICT
|
|
5805
|
+
} else {
|
|
5806
|
+
StatusCode::BAD_REQUEST
|
|
5807
|
+
},
|
|
5808
|
+
error,
|
|
5809
|
+
)
|
|
5810
|
+
})?;
|
|
5811
|
+
state
|
|
5812
|
+
.tenancy
|
|
5813
|
+
.register_platform_user(
|
|
5814
|
+
&identity.subject_id,
|
|
5815
|
+
identity.email.as_deref().unwrap_or(&identity.identity_id),
|
|
4374
5816
|
)
|
|
4375
|
-
|
|
5817
|
+
.map_err(control_error)?;
|
|
5818
|
+
state
|
|
5819
|
+
.tenancy
|
|
5820
|
+
.register_application_actor(&identity.subject_id, &input.application_id)
|
|
5821
|
+
.map_err(control_error)?;
|
|
5822
|
+
Ok((StatusCode::CREATED, auth_response(identity, session, token)))
|
|
4376
5823
|
}
|
|
4377
|
-
async fn
|
|
5824
|
+
async fn auth_sign_in(
|
|
4378
5825
|
State(state): State<AppState>,
|
|
4379
|
-
|
|
4380
|
-
Path(application_id): Path<String>,
|
|
4381
|
-
Query(input): Query<RuntimeEnvironmentRequest>,
|
|
5826
|
+
Json(input): Json<SignInRequest>,
|
|
4382
5827
|
) -> Result<Json<Value>, ApiError> {
|
|
4383
|
-
let
|
|
4384
|
-
.
|
|
4385
|
-
.
|
|
4386
|
-
.
|
|
4387
|
-
|
|
4388
|
-
|
|
4389
|
-
|
|
4390
|
-
|
|
4391
|
-
|
|
4392
|
-
|
|
4393
|
-
|
|
5828
|
+
let (identity, session, token) = state
|
|
5829
|
+
.identities
|
|
5830
|
+
.sign_in(&input.email, &input.password)
|
|
5831
|
+
.map_err(|_| ApiError(StatusCode::UNAUTHORIZED, "invalid credentials".into()))?;
|
|
5832
|
+
Ok(auth_response(identity, session, token))
|
|
5833
|
+
}
|
|
5834
|
+
async fn auth_session(Extension(principal): Extension<Principal>) -> Json<Value> {
|
|
5835
|
+
Json(
|
|
5836
|
+
json!({ "actor": { "user_id": principal.key_id, "identity_id": principal.identity_id }, "session_id": principal.session_id }),
|
|
5837
|
+
)
|
|
5838
|
+
}
|
|
5839
|
+
async fn auth_sign_out(
|
|
5840
|
+
State(state): State<AppState>,
|
|
5841
|
+
Extension(principal): Extension<Principal>,
|
|
5842
|
+
) -> Result<StatusCode, ApiError> {
|
|
5843
|
+
let session = principal.session_id.ok_or(ApiError(
|
|
5844
|
+
StatusCode::BAD_REQUEST,
|
|
5845
|
+
"request is not an actor session".into(),
|
|
4394
5846
|
))?;
|
|
4395
|
-
|
|
4396
|
-
|
|
4397
|
-
&principal.key_id,
|
|
4398
|
-
|
|
4399
|
-
|
|
4400
|
-
|
|
4401
|
-
|
|
4402
|
-
|
|
4403
|
-
|
|
4404
|
-
|
|
5847
|
+
state
|
|
5848
|
+
.identities
|
|
5849
|
+
.revoke_session(&principal.key_id, &session, "user_logout")
|
|
5850
|
+
.map_err(control_error)?;
|
|
5851
|
+
Ok(StatusCode::NO_CONTENT)
|
|
5852
|
+
}
|
|
5853
|
+
async fn application_openapi() -> Json<Value> {
|
|
5854
|
+
Json(json!({
|
|
5855
|
+
"openapi": "3.1.0",
|
|
5856
|
+
"info": { "title": "FeltDB Application Service", "version": "1.0.0" },
|
|
5857
|
+
"servers": [{ "url": "/v1" }],
|
|
5858
|
+
"components": {
|
|
5859
|
+
"securitySchemes": { "bearer": { "type": "http", "scheme": "bearer" } },
|
|
5860
|
+
"schemas": {
|
|
5861
|
+
"Error": { "type": "object", "required": ["code", "message", "request_id"], "properties": {
|
|
5862
|
+
"code": { "type": "string" }, "message": { "type": "string" }, "request_id": { "type": "string" }
|
|
5863
|
+
}},
|
|
5864
|
+
"ApplicationEvent": { "type": "object", "required": ["event_id", "type", "resource", "resource_id", "actor", "timestamp", "payload"] }
|
|
5865
|
+
}
|
|
5866
|
+
},
|
|
5867
|
+
"security": [{ "bearer": [] }],
|
|
5868
|
+
"paths": {
|
|
5869
|
+
"/health": { "get": { "security": [], "responses": { "200": { "description": "Runtime health" } } } },
|
|
5870
|
+
"/application": { "get": { "responses": { "200": { "description": "Active application contract identity" } } } },
|
|
5871
|
+
"/schema": { "get": { "responses": { "200": { "description": "Active application schema" } } } },
|
|
5872
|
+
"/query": { "post": { "responses": { "200": { "description": "Bounded authorized query" } } } },
|
|
5873
|
+
"/transactions": { "post": { "responses": { "200": { "description": "Atomic idempotent mutation" } } } },
|
|
5874
|
+
"/events": { "get": { "responses": { "200": { "description": "Application event stream" } } } },
|
|
5875
|
+
"/workflows/{name}/run": { "post": { "responses": { "201": { "description": "Workflow execution" } } } },
|
|
5876
|
+
"/agents/{name}/run": { "post": { "responses": { "201": { "description": "Agent execution" } } } },
|
|
5877
|
+
"/capabilities/{name}/run": { "post": { "responses": { "200": { "description": "Public capability result" } } } }
|
|
5878
|
+
}
|
|
5879
|
+
}))
|
|
4405
5880
|
}
|
|
4406
5881
|
async fn get_revision_runtime(
|
|
4407
5882
|
State(state): State<AppState>,
|
|
@@ -4802,8 +6277,29 @@ async fn list_runtime_triggers(
|
|
|
4802
6277
|
Ok(Json(json!(contract.triggers)))
|
|
4803
6278
|
}
|
|
4804
6279
|
|
|
4805
|
-
fn state_policy_resource_matches(resource
|
|
4806
|
-
|
|
6280
|
+
fn state_policy_resource_matches(resource: &str, collection: &str) -> bool {
|
|
6281
|
+
resource == collection
|
|
6282
|
+
|| resource
|
|
6283
|
+
.strip_suffix('*')
|
|
6284
|
+
.is_some_and(|prefix| !prefix.contains('*') && collection.starts_with(prefix))
|
|
6285
|
+
}
|
|
6286
|
+
fn matching_state_policy<'a>(
|
|
6287
|
+
contract: &'a ApplicationRuntimeContract,
|
|
6288
|
+
collection: &str,
|
|
6289
|
+
) -> Option<&'a feltdb::application::PolicyDefinition> {
|
|
6290
|
+
contract
|
|
6291
|
+
.policies
|
|
6292
|
+
.definitions
|
|
6293
|
+
.iter()
|
|
6294
|
+
.find(|p| p.resource == collection)
|
|
6295
|
+
.or_else(|| {
|
|
6296
|
+
contract
|
|
6297
|
+
.policies
|
|
6298
|
+
.definitions
|
|
6299
|
+
.iter()
|
|
6300
|
+
.find(|p| state_policy_resource_matches(&p.resource, collection))
|
|
6301
|
+
})
|
|
6302
|
+
}
|
|
4807
6303
|
fn authorize_transaction_collections(
|
|
4808
6304
|
principal: &Principal,
|
|
4809
6305
|
contract: &ApplicationRuntimeContract,
|
|
@@ -4836,14 +6332,14 @@ fn state_authorization(
|
|
|
4836
6332
|
) -> AuthorizationContext {
|
|
4837
6333
|
let subject = format!("{}:{}", principal.subject_type, principal.key_id);
|
|
4838
6334
|
|
|
4839
|
-
let policy=matching_state_policy(contract,collection);
|
|
6335
|
+
let policy = matching_state_policy(contract, collection);
|
|
4840
6336
|
let policy_subject = policy.and_then(|p| {
|
|
4841
|
-
|
|
4842
|
-
|
|
4843
|
-
|
|
4844
|
-
|
|
4845
|
-
|
|
4846
|
-
|
|
6337
|
+
if operation == "read" {
|
|
6338
|
+
p.read.as_ref().and_then(|s| PolicySubject::from_str(s))
|
|
6339
|
+
} else {
|
|
6340
|
+
p.write.as_ref().and_then(|s| PolicySubject::from_str(s))
|
|
6341
|
+
}
|
|
6342
|
+
});
|
|
4847
6343
|
|
|
4848
6344
|
// Evaluate policy if found
|
|
4849
6345
|
let capabilities = if let Some(policy) = policy_subject {
|
|
@@ -4871,7 +6367,16 @@ fn state_authorization(
|
|
|
4871
6367
|
BTreeSet::new()
|
|
4872
6368
|
}
|
|
4873
6369
|
}
|
|
4874
|
-
} else if policy.is_some_and(|p|
|
|
6370
|
+
} else if policy.is_some_and(|p| {
|
|
6371
|
+
p.capabilities.iter().any(|capability| {
|
|
6372
|
+
capability
|
|
6373
|
+
== if operation == "read" {
|
|
6374
|
+
"state:read"
|
|
6375
|
+
} else {
|
|
6376
|
+
"state:write"
|
|
6377
|
+
}
|
|
6378
|
+
})
|
|
6379
|
+
}) {
|
|
4875
6380
|
let capability = if operation == "read" {
|
|
4876
6381
|
"state:read"
|
|
4877
6382
|
} else {
|
|
@@ -5207,16 +6712,25 @@ async fn execute_canonical_transaction(
|
|
|
5207
6712
|
input.transaction.revision_id = contract.revision_id.clone();
|
|
5208
6713
|
input.transaction.schema_version = contract.state.schema.schema_version;
|
|
5209
6714
|
input.transaction.state_namespace = Some(contract.environment.state_namespace.clone());
|
|
5210
|
-
let collections = input
|
|
6715
|
+
let collections = input
|
|
6716
|
+
.transaction
|
|
6717
|
+
.operations
|
|
6718
|
+
.iter()
|
|
5211
6719
|
.map(|operation| operation.collection.clone())
|
|
5212
6720
|
.collect::<Vec<_>>();
|
|
5213
|
-
let (authorized, write_policies) =
|
|
6721
|
+
let (authorized, write_policies) =
|
|
6722
|
+
authorize_transaction_collections(&principal, &contract, &tenant, &collections);
|
|
6723
|
+
input.transaction.authorization = state_authorization_legacy(
|
|
5214
6724
|
&principal,
|
|
5215
|
-
&contract,
|
|
5216
6725
|
&tenant,
|
|
5217
|
-
&
|
|
6726
|
+
&input.application_id,
|
|
6727
|
+
&contract.revision_id,
|
|
6728
|
+
if authorized {
|
|
6729
|
+
"state:write"
|
|
6730
|
+
} else {
|
|
6731
|
+
"state:denied"
|
|
6732
|
+
},
|
|
5218
6733
|
);
|
|
5219
|
-
input.transaction.authorization=state_authorization_legacy(&principal,&tenant,&input.application_id,&contract.revision_id,if authorized{"state:write"}else{"state:denied"});
|
|
5220
6734
|
let result = execute_transaction_with_collection_policies(
|
|
5221
6735
|
&state.db,
|
|
5222
6736
|
&contract.state.schema,
|
|
@@ -6029,6 +7543,51 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
|
|
6029
7543
|
axum::routing::post(validate_state_schema),
|
|
6030
7544
|
)
|
|
6031
7545
|
.route("/v1/schema", get(get_state_schema))
|
|
7546
|
+
.route("/v1/application", get(discover_application))
|
|
7547
|
+
.route("/v1/application/schema", get(inspect_application_schema))
|
|
7548
|
+
.route("/v1/data/{collection}", get(inspect_collection))
|
|
7549
|
+
.route("/v1/data/{collection}/{id}", get(inspect_record))
|
|
7550
|
+
.route("/v1/proposals", get(list_proposals).post(create_proposal))
|
|
7551
|
+
.route("/v1/proposals/{id}", get(get_proposal))
|
|
7552
|
+
.route("/v1/proposals/{id}/history", get(proposal_history))
|
|
7553
|
+
.route(
|
|
7554
|
+
"/v1/proposals/{id}/validate",
|
|
7555
|
+
axum::routing::post(validate_proposal),
|
|
7556
|
+
)
|
|
7557
|
+
.route(
|
|
7558
|
+
"/v1/proposals/{id}/preview",
|
|
7559
|
+
axum::routing::post(preview_proposal),
|
|
7560
|
+
)
|
|
7561
|
+
.route(
|
|
7562
|
+
"/v1/proposals/{id}/approve",
|
|
7563
|
+
axum::routing::post(approve_proposal),
|
|
7564
|
+
)
|
|
7565
|
+
.route(
|
|
7566
|
+
"/v1/proposals/{id}/reject",
|
|
7567
|
+
axum::routing::post(reject_proposal),
|
|
7568
|
+
)
|
|
7569
|
+
.route(
|
|
7570
|
+
"/v1/proposals/{id}/status",
|
|
7571
|
+
get(proposal_status).patch(transition_proposal),
|
|
7572
|
+
)
|
|
7573
|
+
.route("/v1/previews/{id}", get(get_proposal_preview))
|
|
7574
|
+
.route("/v1/previews/{id}/data/{collection}", get(inspect_proposal_preview_data))
|
|
7575
|
+
.route("/v1/previews/{id}/simulate", axum::routing::post(simulate_proposal_preview))
|
|
7576
|
+
.route("/v1/auth/session", get(auth_session))
|
|
7577
|
+
.route("/v1/auth/signout", axum::routing::post(auth_sign_out))
|
|
7578
|
+
.route("/v1/events", get(events))
|
|
7579
|
+
.route(
|
|
7580
|
+
"/v1/workflows/{name}/run",
|
|
7581
|
+
axum::routing::post(start_workflow_run),
|
|
7582
|
+
)
|
|
7583
|
+
.route(
|
|
7584
|
+
"/v1/agents/{name}/run",
|
|
7585
|
+
axum::routing::post(start_agent_run),
|
|
7586
|
+
)
|
|
7587
|
+
.route(
|
|
7588
|
+
"/v1/capabilities/{name}/run",
|
|
7589
|
+
axum::routing::post(execute_application_capability),
|
|
7590
|
+
)
|
|
6032
7591
|
.route("/v1/state/version", get(get_state_version))
|
|
6033
7592
|
.route("/v1/query", axum::routing::post(execute_canonical_query))
|
|
6034
7593
|
.route(
|
|
@@ -6393,6 +7952,10 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
|
|
6393
7952
|
|
|
6394
7953
|
let mut app = Router::new()
|
|
6395
7954
|
.route("/health", get(health))
|
|
7955
|
+
.route("/v1/health", get(health))
|
|
7956
|
+
.route("/v1/openapi.json", get(application_openapi))
|
|
7957
|
+
.route("/v1/auth/signup", axum::routing::post(auth_sign_up))
|
|
7958
|
+
.route("/v1/auth/signin", axum::routing::post(auth_sign_in))
|
|
6396
7959
|
.route("/ready", get(readiness))
|
|
6397
7960
|
.route("/runtime", get(runtime))
|
|
6398
7961
|
.route("/metrics", get(network_metrics))
|
|
@@ -6422,6 +7985,7 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
|
|
6422
7985
|
axum::http::header::CONTENT_TYPE,
|
|
6423
7986
|
axum::http::HeaderName::from_static("feltdb-protocol"),
|
|
6424
7987
|
axum::http::HeaderName::from_static("if-version"),
|
|
7988
|
+
axum::http::HeaderName::from_static("idempotency-key"),
|
|
6425
7989
|
]),
|
|
6426
7990
|
);
|
|
6427
7991
|
}
|
|
@@ -6472,6 +8036,7 @@ async fn authenticate(
|
|
|
6472
8036
|
.and_then(|header| header.to_str().ok())
|
|
6473
8037
|
.and_then(|header| header.strip_prefix("Bearer "));
|
|
6474
8038
|
let machine = token.and_then(|token| state.keys.authenticate(token, &state.namespace));
|
|
8039
|
+
let actor = token.and_then(|token| state.identities.authenticate_session(token));
|
|
6475
8040
|
let human = request
|
|
6476
8041
|
.headers()
|
|
6477
8042
|
.get(COOKIE)
|
|
@@ -6483,9 +8048,25 @@ async fn authenticate(
|
|
|
6483
8048
|
.find_map(|cookie| cookie.strip_prefix("feltdb_session="))
|
|
6484
8049
|
})
|
|
6485
8050
|
.and_then(|token| state.sessions.verify(token));
|
|
6486
|
-
match (machine, human) {
|
|
6487
|
-
(Some(principal), _) => principal,
|
|
6488
|
-
(None, Some(session)) => {
|
|
8051
|
+
match (machine, actor, human) {
|
|
8052
|
+
(Some(principal), _, _) => principal,
|
|
8053
|
+
(None, Some(session), _) => Principal {
|
|
8054
|
+
key_id: session.subject_id,
|
|
8055
|
+
scopes: vec![
|
|
8056
|
+
"application:read".into(),
|
|
8057
|
+
"application:revision:read".into(),
|
|
8058
|
+
"state:read".into(),
|
|
8059
|
+
"state:write".into(),
|
|
8060
|
+
"events:read".into(),
|
|
8061
|
+
"workflows:run".into(),
|
|
8062
|
+
"agents:run".into(),
|
|
8063
|
+
"capabilities:run".into(),
|
|
8064
|
+
],
|
|
8065
|
+
subject_type: "human".into(),
|
|
8066
|
+
identity_id: Some(session.identity_id),
|
|
8067
|
+
session_id: Some(session.session_id),
|
|
8068
|
+
},
|
|
8069
|
+
(None, None, Some(session)) => {
|
|
6489
8070
|
if state
|
|
6490
8071
|
.tenancy
|
|
6491
8072
|
.register_platform_user(&session.user_id, &session.identity)
|
|
@@ -6503,7 +8084,7 @@ async fn authenticate(
|
|
|
6503
8084
|
session_id: Some(session.id),
|
|
6504
8085
|
}
|
|
6505
8086
|
}
|
|
6506
|
-
(None, None) => {
|
|
8087
|
+
(None, None, None) => {
|
|
6507
8088
|
state.metrics.authentication_failure();
|
|
6508
8089
|
audit(
|
|
6509
8090
|
&state,
|
|
@@ -6711,9 +8292,7 @@ async fn health(State(state): State<AppState>) -> Json<HealthResponse<'static>>
|
|
|
6711
8292
|
})
|
|
6712
8293
|
}
|
|
6713
8294
|
|
|
6714
|
-
async fn managed_diagnostics(
|
|
6715
|
-
State(state): State<AppState>,
|
|
6716
|
-
) -> Json<ManagedDiagnosticsResponse> {
|
|
8295
|
+
async fn managed_diagnostics(State(state): State<AppState>) -> Json<ManagedDiagnosticsResponse> {
|
|
6717
8296
|
let recovery = match state.cluster.proposal().map(|value| value.phase) {
|
|
6718
8297
|
Some(ProposalPhase::Preparing | ProposalPhase::Prepared) => "recovering",
|
|
6719
8298
|
Some(ProposalPhase::Aborted) => "attention-required",
|
|
@@ -6730,8 +8309,7 @@ async fn managed_diagnostics(
|
|
|
6730
8309
|
storage_format: 1,
|
|
6731
8310
|
promoted_revision: std::env::var("FELTDB_PROMOTED_REVISION")
|
|
6732
8311
|
.unwrap_or_else(|_| "unknown".into()),
|
|
6733
|
-
environment: std::env::var("FELTDB_ENVIRONMENT")
|
|
6734
|
-
.unwrap_or_else(|_| "development".into()),
|
|
8312
|
+
environment: std::env::var("FELTDB_ENVIRONMENT").unwrap_or_else(|_| "development".into()),
|
|
6735
8313
|
recovery: recovery.into(),
|
|
6736
8314
|
uptime: format!("{uptime_seconds}s"),
|
|
6737
8315
|
})
|
|
@@ -8476,13 +10054,67 @@ async fn define_capability(
|
|
|
8476
10054
|
|
|
8477
10055
|
async fn execute_capability(
|
|
8478
10056
|
State(state): State<AppState>,
|
|
10057
|
+
Extension(principal): Extension<Principal>,
|
|
8479
10058
|
Path(name): Path<String>,
|
|
8480
10059
|
Json(input): Json<Value>,
|
|
8481
10060
|
) -> Result<Json<Value>, ApiError> {
|
|
10061
|
+
if !principal.permits("capabilities:run") {
|
|
10062
|
+
return Err(ApiError(
|
|
10063
|
+
StatusCode::FORBIDDEN,
|
|
10064
|
+
"capability execution is not permitted".into(),
|
|
10065
|
+
));
|
|
10066
|
+
}
|
|
8482
10067
|
validate_segment(&name)?;
|
|
8483
10068
|
Ok(Json(execute_named_capability(&state, &name, &input)?))
|
|
8484
10069
|
}
|
|
8485
10070
|
|
|
10071
|
+
async fn execute_application_capability(
|
|
10072
|
+
State(state): State<AppState>,
|
|
10073
|
+
Extension(principal): Extension<Principal>,
|
|
10074
|
+
Path(name): Path<String>,
|
|
10075
|
+
Query(input): Query<ApplicationDiscoveryRequest>,
|
|
10076
|
+
Json(payload): Json<Value>,
|
|
10077
|
+
) -> Result<Json<Value>, ApiError> {
|
|
10078
|
+
let tenant = application_scope(
|
|
10079
|
+
&state,
|
|
10080
|
+
&principal.key_id,
|
|
10081
|
+
&input.application_id,
|
|
10082
|
+
"capabilities:run",
|
|
10083
|
+
)?;
|
|
10084
|
+
let revision_id = state
|
|
10085
|
+
.applications
|
|
10086
|
+
.pointers(&input.application_id)
|
|
10087
|
+
.get(&input.environment)
|
|
10088
|
+
.cloned()
|
|
10089
|
+
.ok_or(ApiError(
|
|
10090
|
+
StatusCode::NOT_FOUND,
|
|
10091
|
+
"application environment has no active contract".into(),
|
|
10092
|
+
))?;
|
|
10093
|
+
let revision = state
|
|
10094
|
+
.applications
|
|
10095
|
+
.revision(&tenant, &input.application_id, &revision_id)
|
|
10096
|
+
.ok_or(ApiError(
|
|
10097
|
+
StatusCode::NOT_FOUND,
|
|
10098
|
+
"application revision not found".into(),
|
|
10099
|
+
))?;
|
|
10100
|
+
let capability = revision
|
|
10101
|
+
.manifest
|
|
10102
|
+
.capabilities
|
|
10103
|
+
.iter()
|
|
10104
|
+
.find(|value| value.name == name)
|
|
10105
|
+
.ok_or(ApiError(
|
|
10106
|
+
StatusCode::NOT_FOUND,
|
|
10107
|
+
"capability not found".into(),
|
|
10108
|
+
))?;
|
|
10109
|
+
if capability.visibility != "public" {
|
|
10110
|
+
return Err(ApiError(
|
|
10111
|
+
StatusCode::FORBIDDEN,
|
|
10112
|
+
"capability is not externally invocable".into(),
|
|
10113
|
+
));
|
|
10114
|
+
}
|
|
10115
|
+
Ok(Json(execute_named_capability(&state, &name, &payload)?))
|
|
10116
|
+
}
|
|
10117
|
+
|
|
8486
10118
|
fn validate_capability_program(program: &CapabilityProgram) -> Result<(), ApiError> {
|
|
8487
10119
|
if program.steps.is_empty() || program.steps.len() > 16 {
|
|
8488
10120
|
return Err(ApiError(
|
|
@@ -8668,9 +10300,16 @@ async fn define_workflow(
|
|
|
8668
10300
|
|
|
8669
10301
|
async fn start_workflow_run(
|
|
8670
10302
|
State(state): State<AppState>,
|
|
10303
|
+
Extension(principal): Extension<Principal>,
|
|
8671
10304
|
Path(name): Path<String>,
|
|
8672
10305
|
Json(request): Json<StartRun>,
|
|
8673
10306
|
) -> Result<(StatusCode, Json<RecordResponse>), ApiError> {
|
|
10307
|
+
if !principal.permits("workflows:run") {
|
|
10308
|
+
return Err(ApiError(
|
|
10309
|
+
StatusCode::FORBIDDEN,
|
|
10310
|
+
"workflow execution is not permitted".into(),
|
|
10311
|
+
));
|
|
10312
|
+
}
|
|
8674
10313
|
validate_segment(&name)?;
|
|
8675
10314
|
let definition = required_value(&state, "_flow_workflows", &name, "workflow not found")?;
|
|
8676
10315
|
let steps = definition["steps"].as_array().ok_or_else(|| {
|
|
@@ -8828,9 +10467,16 @@ async fn define_agent(
|
|
|
8828
10467
|
|
|
8829
10468
|
async fn start_agent_run(
|
|
8830
10469
|
State(state): State<AppState>,
|
|
10470
|
+
Extension(principal): Extension<Principal>,
|
|
8831
10471
|
Path(name): Path<String>,
|
|
8832
10472
|
Json(request): Json<StartAgentRun>,
|
|
8833
10473
|
) -> Result<(StatusCode, Json<RecordResponse>), ApiError> {
|
|
10474
|
+
if !principal.permits("agents:run") {
|
|
10475
|
+
return Err(ApiError(
|
|
10476
|
+
StatusCode::FORBIDDEN,
|
|
10477
|
+
"agent execution is not permitted".into(),
|
|
10478
|
+
));
|
|
10479
|
+
}
|
|
8834
10480
|
validate_segment(&name)?;
|
|
8835
10481
|
required_value(&state, "_flow_agents", &name, "agent not found")?;
|
|
8836
10482
|
if request.goal.trim().is_empty() {
|
|
@@ -9052,10 +10698,13 @@ struct BoundedQueryPage {
|
|
|
9052
10698
|
}
|
|
9053
10699
|
|
|
9054
10700
|
fn bounded_query_error(code: &str, message: impl Into<String>) -> ApiError {
|
|
9055
|
-
ApiError::structured(
|
|
9056
|
-
|
|
9057
|
-
|
|
9058
|
-
|
|
10701
|
+
ApiError::structured(
|
|
10702
|
+
StatusCode::UNPROCESSABLE_ENTITY,
|
|
10703
|
+
json!({
|
|
10704
|
+
"code": code,
|
|
10705
|
+
"message": message.into(),
|
|
10706
|
+
}),
|
|
10707
|
+
)
|
|
9059
10708
|
}
|
|
9060
10709
|
|
|
9061
10710
|
fn bounded_query_hash(request: &BoundedQueryRequest) -> Result<String, ApiError> {
|
|
@@ -9078,7 +10727,10 @@ fn query_scalar_cmp(left: Option<&Value>, right: Option<&Value>) -> std::cmp::Or
|
|
|
9078
10727
|
(Some(Value::Null), Some(Value::Null)) => std::cmp::Ordering::Equal,
|
|
9079
10728
|
(Some(Value::Null), Some(_)) => std::cmp::Ordering::Less,
|
|
9080
10729
|
(Some(_), Some(Value::Null)) => std::cmp::Ordering::Greater,
|
|
9081
|
-
(Some(Value::Number(a)), Some(Value::Number(b))) => a
|
|
10730
|
+
(Some(Value::Number(a)), Some(Value::Number(b))) => a
|
|
10731
|
+
.as_f64()
|
|
10732
|
+
.partial_cmp(&b.as_f64())
|
|
10733
|
+
.unwrap_or(std::cmp::Ordering::Equal),
|
|
9082
10734
|
(Some(Value::String(a)), Some(Value::String(b))) => a.cmp(b),
|
|
9083
10735
|
(Some(Value::Bool(a)), Some(Value::Bool(b))) => a.cmp(b),
|
|
9084
10736
|
(Some(a), Some(b)) => a.to_string().cmp(&b.to_string()),
|
|
@@ -9087,31 +10739,60 @@ fn query_scalar_cmp(left: Option<&Value>, right: Option<&Value>) -> std::cmp::Or
|
|
|
9087
10739
|
|
|
9088
10740
|
fn condition_matches(record: &Value, condition: &BoundedQueryCondition) -> Result<bool, ApiError> {
|
|
9089
10741
|
if condition.operators.len() != 1 || condition.field.trim().is_empty() {
|
|
9090
|
-
return Err(bounded_query_error(
|
|
10742
|
+
return Err(bounded_query_error(
|
|
10743
|
+
"INVALID_QUERY",
|
|
10744
|
+
"each where condition requires a field and exactly one operator",
|
|
10745
|
+
));
|
|
9091
10746
|
}
|
|
9092
|
-
let (operator, expected) = condition
|
|
10747
|
+
let (operator, expected) = condition
|
|
10748
|
+
.operators
|
|
10749
|
+
.iter()
|
|
10750
|
+
.next()
|
|
10751
|
+
.expect("validated operator");
|
|
9093
10752
|
let actual = record.get(&condition.field);
|
|
9094
|
-
if operator == "eq" {
|
|
9095
|
-
|
|
9096
|
-
|
|
10753
|
+
if operator == "eq" {
|
|
10754
|
+
return Ok(actual == Some(expected));
|
|
10755
|
+
}
|
|
10756
|
+
if operator == "neq" {
|
|
10757
|
+
return Ok(actual != Some(expected));
|
|
10758
|
+
}
|
|
10759
|
+
let Some(actual) = actual else {
|
|
10760
|
+
return Ok(false);
|
|
10761
|
+
};
|
|
9097
10762
|
let comparison = query_scalar_cmp(Some(actual), Some(expected));
|
|
9098
10763
|
Ok(match operator.as_str() {
|
|
9099
10764
|
"lt" => comparison.is_lt(),
|
|
9100
10765
|
"lte" => !comparison.is_gt(),
|
|
9101
10766
|
"gt" => comparison.is_gt(),
|
|
9102
10767
|
"gte" => !comparison.is_lt(),
|
|
9103
|
-
_ =>
|
|
10768
|
+
_ => {
|
|
10769
|
+
return Err(bounded_query_error(
|
|
10770
|
+
"INVALID_QUERY",
|
|
10771
|
+
format!("unsupported where operator: {operator}"),
|
|
10772
|
+
))
|
|
10773
|
+
}
|
|
9104
10774
|
})
|
|
9105
10775
|
}
|
|
9106
10776
|
|
|
9107
|
-
fn issue_bounded_cursor(
|
|
10777
|
+
fn issue_bounded_cursor(
|
|
10778
|
+
state: &AppState,
|
|
10779
|
+
mut cursor: BoundedQueryCursor,
|
|
10780
|
+
) -> Result<String, ApiError> {
|
|
9108
10781
|
let token = uuid::Uuid::new_v4().simple().to_string();
|
|
9109
10782
|
let now = unix_seconds_i64().max(0) as u64;
|
|
9110
10783
|
cursor.created_at = now;
|
|
9111
|
-
let mut cursors = state.bounded_query_cursors.lock().map_err(|_|
|
|
10784
|
+
let mut cursors = state.bounded_query_cursors.lock().map_err(|_| {
|
|
10785
|
+
ApiError(
|
|
10786
|
+
StatusCode::INTERNAL_SERVER_ERROR,
|
|
10787
|
+
"query cursor store unavailable".into(),
|
|
10788
|
+
)
|
|
10789
|
+
})?;
|
|
9112
10790
|
cursors.retain(|_, value| now.saturating_sub(value.created_at) <= BOUNDED_CURSOR_TTL_SECONDS);
|
|
9113
10791
|
if cursors.len() >= 1024 {
|
|
9114
|
-
return Err(ApiError(
|
|
10792
|
+
return Err(ApiError(
|
|
10793
|
+
StatusCode::SERVICE_UNAVAILABLE,
|
|
10794
|
+
"query cursor capacity exhausted".into(),
|
|
10795
|
+
));
|
|
9115
10796
|
}
|
|
9116
10797
|
cursors.insert(token.clone(), cursor);
|
|
9117
10798
|
Ok(token)
|
|
@@ -9124,32 +10805,67 @@ async fn execute_bounded_query(
|
|
|
9124
10805
|
) -> Result<Json<BoundedQueryPage>, ApiError> {
|
|
9125
10806
|
validate_segment(&request.collection)?;
|
|
9126
10807
|
if request.limit == 0 || request.limit > MAX_BOUNDED_QUERY_LIMIT {
|
|
9127
|
-
return Err(bounded_query_error(
|
|
10808
|
+
return Err(bounded_query_error(
|
|
10809
|
+
"INVALID_QUERY",
|
|
10810
|
+
format!("limit must be between 1 and {MAX_BOUNDED_QUERY_LIMIT}"),
|
|
10811
|
+
));
|
|
9128
10812
|
}
|
|
9129
10813
|
if request.order_by.is_empty() {
|
|
9130
|
-
return Err(bounded_query_error(
|
|
10814
|
+
return Err(bounded_query_error(
|
|
10815
|
+
"INVALID_QUERY",
|
|
10816
|
+
"orderBy must contain at least one field",
|
|
10817
|
+
));
|
|
9131
10818
|
}
|
|
9132
10819
|
for order in &request.order_by {
|
|
9133
10820
|
if order.field.trim().is_empty() || !matches!(order.direction.as_str(), "asc" | "desc") {
|
|
9134
|
-
return Err(bounded_query_error(
|
|
10821
|
+
return Err(bounded_query_error(
|
|
10822
|
+
"INVALID_QUERY",
|
|
10823
|
+
"orderBy directions must be asc or desc",
|
|
10824
|
+
));
|
|
9135
10825
|
}
|
|
9136
10826
|
}
|
|
9137
10827
|
for condition in &request.conditions {
|
|
9138
|
-
if condition.operators.len() != 1
|
|
9139
|
-
||
|
|
9140
|
-
|
|
10828
|
+
if condition.operators.len() != 1
|
|
10829
|
+
|| condition.field.trim().is_empty()
|
|
10830
|
+
|| !condition.operators.keys().all(|operator| {
|
|
10831
|
+
matches!(
|
|
10832
|
+
operator.as_str(),
|
|
10833
|
+
"eq" | "neq" | "lt" | "lte" | "gt" | "gte"
|
|
10834
|
+
)
|
|
10835
|
+
})
|
|
10836
|
+
{
|
|
10837
|
+
return Err(bounded_query_error(
|
|
10838
|
+
"INVALID_QUERY",
|
|
10839
|
+
"each where condition requires a field and exactly one operator",
|
|
10840
|
+
));
|
|
9141
10841
|
}
|
|
9142
10842
|
}
|
|
9143
10843
|
let hash = bounded_query_hash(&request)?;
|
|
9144
10844
|
let (records, position) = if let Some(token) = &request.cursor {
|
|
9145
|
-
let cursor = state
|
|
9146
|
-
.
|
|
9147
|
-
.
|
|
10845
|
+
let cursor = state
|
|
10846
|
+
.bounded_query_cursors
|
|
10847
|
+
.lock()
|
|
10848
|
+
.map_err(|_| {
|
|
10849
|
+
ApiError(
|
|
10850
|
+
StatusCode::INTERNAL_SERVER_ERROR,
|
|
10851
|
+
"query cursor store unavailable".into(),
|
|
10852
|
+
)
|
|
10853
|
+
})?
|
|
10854
|
+
.get(token)
|
|
10855
|
+
.cloned()
|
|
9148
10856
|
.ok_or_else(|| bounded_query_error("INVALID_CURSOR", "cursor is invalid or expired"))?;
|
|
9149
|
-
if cursor.query_hash != hash
|
|
9150
|
-
|
|
10857
|
+
if cursor.query_hash != hash
|
|
10858
|
+
|| cursor.principal_key_id != principal.key_id
|
|
10859
|
+
|| cursor.namespace != state.namespace.as_ref()
|
|
10860
|
+
{
|
|
10861
|
+
return Err(bounded_query_error(
|
|
10862
|
+
"INVALID_CURSOR",
|
|
10863
|
+
"cursor does not match this query, principal, or namespace",
|
|
10864
|
+
));
|
|
9151
10865
|
}
|
|
9152
|
-
if (unix_seconds_i64().max(0) as u64).saturating_sub(cursor.created_at)
|
|
10866
|
+
if (unix_seconds_i64().max(0) as u64).saturating_sub(cursor.created_at)
|
|
10867
|
+
> BOUNDED_CURSOR_TTL_SECONDS
|
|
10868
|
+
{
|
|
9153
10869
|
return Err(bounded_query_error("INVALID_CURSOR", "cursor is expired"));
|
|
9154
10870
|
}
|
|
9155
10871
|
(cursor.records, cursor.position)
|
|
@@ -9158,13 +10874,21 @@ async fn execute_bounded_query(
|
|
|
9158
10874
|
for row in state.db.list_collection(&request.collection)? {
|
|
9159
10875
|
let mut value = row.value;
|
|
9160
10876
|
if let Some(object) = value.as_object_mut() {
|
|
9161
|
-
let id = row
|
|
10877
|
+
let id = row
|
|
10878
|
+
.key
|
|
10879
|
+
.split_once(':')
|
|
10880
|
+
.map(|(_, id)| id)
|
|
10881
|
+
.unwrap_or(&row.key);
|
|
9162
10882
|
// `recordId` is authority metadata for this query surface, not
|
|
9163
10883
|
// caller-controlled document data. It is the final total-order
|
|
9164
10884
|
// tie-breaker even when a document contains a field by that name.
|
|
9165
10885
|
object.insert("recordId".into(), Value::String(id.to_string()));
|
|
9166
10886
|
}
|
|
9167
|
-
if request
|
|
10887
|
+
if request
|
|
10888
|
+
.conditions
|
|
10889
|
+
.iter()
|
|
10890
|
+
.all(|condition| condition_matches(&value, condition).unwrap_or(false))
|
|
10891
|
+
{
|
|
9168
10892
|
records.push(value);
|
|
9169
10893
|
}
|
|
9170
10894
|
}
|
|
@@ -9173,7 +10897,13 @@ async fn execute_bounded_query(
|
|
|
9173
10897
|
records.sort_by(|left, right| {
|
|
9174
10898
|
for order in &request.order_by {
|
|
9175
10899
|
let comparison = query_scalar_cmp(left.get(&order.field), right.get(&order.field));
|
|
9176
|
-
if !comparison.is_eq() {
|
|
10900
|
+
if !comparison.is_eq() {
|
|
10901
|
+
return if order.direction == "desc" {
|
|
10902
|
+
comparison.reverse()
|
|
10903
|
+
} else {
|
|
10904
|
+
comparison
|
|
10905
|
+
};
|
|
10906
|
+
}
|
|
9177
10907
|
}
|
|
9178
10908
|
query_scalar_cmp(left.get("recordId"), right.get("recordId"))
|
|
9179
10909
|
});
|
|
@@ -9182,22 +10912,37 @@ async fn execute_bounded_query(
|
|
|
9182
10912
|
let end = position.saturating_add(request.limit).min(records.len());
|
|
9183
10913
|
let page = records[position.min(records.len())..end].to_vec();
|
|
9184
10914
|
let next_cursor = if end < records.len() {
|
|
9185
|
-
Some(issue_bounded_cursor(
|
|
9186
|
-
|
|
9187
|
-
|
|
9188
|
-
|
|
9189
|
-
|
|
9190
|
-
|
|
9191
|
-
|
|
9192
|
-
|
|
9193
|
-
|
|
9194
|
-
|
|
10915
|
+
Some(issue_bounded_cursor(
|
|
10916
|
+
&state,
|
|
10917
|
+
BoundedQueryCursor {
|
|
10918
|
+
query_hash: hash,
|
|
10919
|
+
principal_key_id: principal.key_id,
|
|
10920
|
+
namespace: state.namespace.to_string(),
|
|
10921
|
+
records: records.clone(),
|
|
10922
|
+
position: end,
|
|
10923
|
+
created_at: 0,
|
|
10924
|
+
},
|
|
10925
|
+
)?)
|
|
10926
|
+
} else {
|
|
10927
|
+
None
|
|
10928
|
+
};
|
|
10929
|
+
Ok(Json(BoundedQueryPage {
|
|
10930
|
+
records: page,
|
|
10931
|
+
exhausted: next_cursor.is_none(),
|
|
10932
|
+
next_cursor,
|
|
10933
|
+
}))
|
|
9195
10934
|
}
|
|
9196
10935
|
|
|
9197
10936
|
async fn list_records(
|
|
9198
10937
|
State(state): State<AppState>,
|
|
9199
10938
|
Path(collection): Path<String>,
|
|
9200
10939
|
) -> Result<Json<Vec<RecordResponse>>, ApiError> {
|
|
10940
|
+
if collection == PROPOSAL_COLLECTION || collection == PROPOSAL_EVENT_COLLECTION {
|
|
10941
|
+
return Err(ApiError(
|
|
10942
|
+
StatusCode::FORBIDDEN,
|
|
10943
|
+
"system collection requires proposal service".into(),
|
|
10944
|
+
));
|
|
10945
|
+
}
|
|
9201
10946
|
validate_segment(&collection)?;
|
|
9202
10947
|
let rows = state.db.list_collection(&collection)?;
|
|
9203
10948
|
Ok(Json(rows.into_iter().map(record_response).collect()))
|
|
@@ -9207,6 +10952,12 @@ async fn get_record(
|
|
|
9207
10952
|
State(state): State<AppState>,
|
|
9208
10953
|
Path((collection, id)): Path<(String, String)>,
|
|
9209
10954
|
) -> Result<Json<RecordResponse>, ApiError> {
|
|
10955
|
+
if collection == PROPOSAL_COLLECTION || collection == PROPOSAL_EVENT_COLLECTION {
|
|
10956
|
+
return Err(ApiError(
|
|
10957
|
+
StatusCode::FORBIDDEN,
|
|
10958
|
+
"system collection requires proposal service".into(),
|
|
10959
|
+
));
|
|
10960
|
+
}
|
|
9210
10961
|
let key = record_key(&collection, &id)?;
|
|
9211
10962
|
let value = state
|
|
9212
10963
|
.db
|
|
@@ -9427,7 +11178,11 @@ async fn commit_transaction(
|
|
|
9427
11178
|
})?;
|
|
9428
11179
|
|
|
9429
11180
|
Ok((
|
|
9430
|
-
if commit.duplicate {
|
|
11181
|
+
if commit.duplicate {
|
|
11182
|
+
StatusCode::OK
|
|
11183
|
+
} else {
|
|
11184
|
+
StatusCode::CREATED
|
|
11185
|
+
},
|
|
9431
11186
|
Json(AtomicTxResponse {
|
|
9432
11187
|
transaction_id: commit.transaction_id,
|
|
9433
11188
|
duplicate: commit.duplicate,
|
|
@@ -9443,6 +11198,12 @@ async fn create_record(
|
|
|
9443
11198
|
Path(collection): Path<String>,
|
|
9444
11199
|
Json(mut record): Json<CreateRecord>,
|
|
9445
11200
|
) -> Result<(StatusCode, Json<RecordResponse>), ApiError> {
|
|
11201
|
+
if collection == PROPOSAL_COLLECTION || collection == PROPOSAL_EVENT_COLLECTION {
|
|
11202
|
+
return Err(ApiError(
|
|
11203
|
+
StatusCode::FORBIDDEN,
|
|
11204
|
+
"system collection requires proposal service".into(),
|
|
11205
|
+
));
|
|
11206
|
+
}
|
|
9446
11207
|
validate_segment(&collection)?;
|
|
9447
11208
|
let id = record
|
|
9448
11209
|
.id
|
|
@@ -9476,6 +11237,12 @@ async fn update_record(
|
|
|
9476
11237
|
Path((collection, id)): Path<(String, String)>,
|
|
9477
11238
|
Json(changes): Json<Map<String, Value>>,
|
|
9478
11239
|
) -> Result<Json<RecordResponse>, ApiError> {
|
|
11240
|
+
if collection == PROPOSAL_COLLECTION || collection == PROPOSAL_EVENT_COLLECTION {
|
|
11241
|
+
return Err(ApiError(
|
|
11242
|
+
StatusCode::FORBIDDEN,
|
|
11243
|
+
"system collection requires proposal service".into(),
|
|
11244
|
+
));
|
|
11245
|
+
}
|
|
9479
11246
|
let key = record_key(&collection, &id)?;
|
|
9480
11247
|
let current = state
|
|
9481
11248
|
.db
|
|
@@ -9591,6 +11358,12 @@ async fn delete_record(
|
|
|
9591
11358
|
State(state): State<AppState>,
|
|
9592
11359
|
Path((collection, id)): Path<(String, String)>,
|
|
9593
11360
|
) -> Result<StatusCode, ApiError> {
|
|
11361
|
+
if collection == PROPOSAL_COLLECTION || collection == PROPOSAL_EVENT_COLLECTION {
|
|
11362
|
+
return Err(ApiError(
|
|
11363
|
+
StatusCode::FORBIDDEN,
|
|
11364
|
+
"system collection requires proposal service".into(),
|
|
11365
|
+
));
|
|
11366
|
+
}
|
|
9594
11367
|
let key = record_key(&collection, &id)?;
|
|
9595
11368
|
if state.db.get_value(&key)?.is_none() {
|
|
9596
11369
|
return Err(ApiError(
|
|
@@ -9604,14 +11377,36 @@ async fn delete_record(
|
|
|
9604
11377
|
|
|
9605
11378
|
async fn events(
|
|
9606
11379
|
State(state): State<AppState>,
|
|
9607
|
-
)
|
|
11380
|
+
Extension(principal): Extension<Principal>,
|
|
11381
|
+
Query(scope): Query<EventScopeRequest>,
|
|
11382
|
+
) -> Result<Sse<impl futures_core::Stream<Item = Result<Event, Infallible>>>, ApiError> {
|
|
11383
|
+
if !principal.permits("events:read") {
|
|
11384
|
+
return Err(ApiError(
|
|
11385
|
+
StatusCode::FORBIDDEN,
|
|
11386
|
+
"event access is not permitted".into(),
|
|
11387
|
+
));
|
|
11388
|
+
}
|
|
11389
|
+
if let Some(application_id) = scope.application_id {
|
|
11390
|
+
application_scope(&state, &principal.key_id, &application_id, "events:read")?;
|
|
11391
|
+
}
|
|
9608
11392
|
let mut receiver = state.db.subscribe_changes();
|
|
9609
11393
|
let namespace = state.namespace.clone();
|
|
11394
|
+
let actor = principal.key_id;
|
|
9610
11395
|
let stream = async_stream::stream! {
|
|
9611
11396
|
loop {
|
|
9612
11397
|
match receiver.recv().await {
|
|
9613
11398
|
Ok(change) => {
|
|
9614
|
-
let
|
|
11399
|
+
let resource = change.capability.clone();
|
|
11400
|
+
let resource_id = change.key.split_once(':').map(|(_, id)| id).unwrap_or(&change.key);
|
|
11401
|
+
let data = json!({
|
|
11402
|
+
"event_id": uuid::Uuid::new_v4().to_string(),
|
|
11403
|
+
"type": "state.changed",
|
|
11404
|
+
"resource": resource,
|
|
11405
|
+
"resource_id": resource_id,
|
|
11406
|
+
"actor": actor,
|
|
11407
|
+
"timestamp": unix_seconds_i64().max(0) as u64,
|
|
11408
|
+
"payload": { "operation": "mutation", "namespace": namespace.as_ref() }
|
|
11409
|
+
});
|
|
9615
11410
|
yield Ok(Event::default().event("state.changed").json_data(data).expect("serializable event"));
|
|
9616
11411
|
}
|
|
9617
11412
|
Err(tokio::sync::broadcast::error::RecvError::Lagged(skipped)) => {
|
|
@@ -9621,7 +11416,7 @@ async fn events(
|
|
|
9621
11416
|
}
|
|
9622
11417
|
}
|
|
9623
11418
|
};
|
|
9624
|
-
Sse::new(stream).keep_alive(KeepAlive::new().interval(Duration::from_secs(15)))
|
|
11419
|
+
Ok(Sse::new(stream).keep_alive(KeepAlive::new().interval(Duration::from_secs(15))))
|
|
9625
11420
|
}
|
|
9626
11421
|
|
|
9627
11422
|
fn record_response(row: StoredRow) -> RecordResponse {
|
|
@@ -9683,7 +11478,10 @@ mod authority_gate_tests {
|
|
|
9683
11478
|
use super::{api_error_body, authorize_transaction_collections, state_authorization};
|
|
9684
11479
|
use axum::http::StatusCode;
|
|
9685
11480
|
use feltdb::{
|
|
9686
|
-
application::{
|
|
11481
|
+
application::{
|
|
11482
|
+
manifest_hash, ApplicationManifest, ApplicationRevision, PolicyDefinition,
|
|
11483
|
+
RevisionStatus,
|
|
11484
|
+
},
|
|
9687
11485
|
application_runtime::{resolve_runtime, ApplicationRuntimeContract, RuntimeInventory},
|
|
9688
11486
|
};
|
|
9689
11487
|
use feltdb_server::auth::Principal;
|
|
@@ -9729,9 +11527,16 @@ mod authority_gate_tests {
|
|
|
9729
11527
|
#[test]
|
|
9730
11528
|
fn no_matching_policy_denies() {
|
|
9731
11529
|
let contract = contract(vec![]);
|
|
9732
|
-
assert!(
|
|
11530
|
+
assert!(
|
|
11531
|
+
state_authorization(&principal(), &contract, "secrets", "read")
|
|
11532
|
+
.capabilities
|
|
11533
|
+
.is_empty()
|
|
11534
|
+
);
|
|
9733
11535
|
let (allowed, _) = authorize_transaction_collections(
|
|
9734
|
-
&principal(),
|
|
11536
|
+
&principal(),
|
|
11537
|
+
&contract,
|
|
11538
|
+
"tenant",
|
|
11539
|
+
&["secrets".into()],
|
|
9735
11540
|
);
|
|
9736
11541
|
assert!(!allowed);
|
|
9737
11542
|
}
|
|
@@ -9740,7 +11545,10 @@ mod authority_gate_tests {
|
|
|
9740
11545
|
fn terminal_wildcard_policy_allows_matching_collection() {
|
|
9741
11546
|
let contract = contract(vec![allow("buzz_*")]);
|
|
9742
11547
|
let (allowed, _) = authorize_transaction_collections(
|
|
9743
|
-
&principal(),
|
|
11548
|
+
&principal(),
|
|
11549
|
+
&contract,
|
|
11550
|
+
"tenant",
|
|
11551
|
+
&["buzz_rooms".into()],
|
|
9744
11552
|
);
|
|
9745
11553
|
assert!(allowed);
|
|
9746
11554
|
}
|
|
@@ -9749,10 +11557,16 @@ mod authority_gate_tests {
|
|
|
9749
11557
|
fn multi_collection_transaction_authorizes_every_operation() {
|
|
9750
11558
|
let contract = contract(vec![allow("alpha"), allow("beta")]);
|
|
9751
11559
|
let (all_allowed, _) = authorize_transaction_collections(
|
|
9752
|
-
&principal(),
|
|
11560
|
+
&principal(),
|
|
11561
|
+
&contract,
|
|
11562
|
+
"tenant",
|
|
11563
|
+
&["alpha".into(), "beta".into()],
|
|
9753
11564
|
);
|
|
9754
11565
|
let (mixed_allowed, _) = authorize_transaction_collections(
|
|
9755
|
-
&principal(),
|
|
11566
|
+
&principal(),
|
|
11567
|
+
&contract,
|
|
11568
|
+
"tenant",
|
|
11569
|
+
&["alpha".into(), "secrets".into()],
|
|
9756
11570
|
);
|
|
9757
11571
|
assert!(all_allowed);
|
|
9758
11572
|
assert!(!mixed_allowed);
|
|
@@ -9762,7 +11576,10 @@ mod authority_gate_tests {
|
|
|
9762
11576
|
fn cross_tenant_transaction_denies() {
|
|
9763
11577
|
let contract = contract(vec![allow("alpha")]);
|
|
9764
11578
|
let (allowed, _) = authorize_transaction_collections(
|
|
9765
|
-
&principal(),
|
|
11579
|
+
&principal(),
|
|
11580
|
+
&contract,
|
|
11581
|
+
"other-tenant",
|
|
11582
|
+
&["alpha".into()],
|
|
9766
11583
|
);
|
|
9767
11584
|
assert!(!allowed);
|
|
9768
11585
|
}
|
|
@@ -9775,10 +11592,10 @@ mod authority_gate_tests {
|
|
|
9775
11592
|
"missing scope: state:read",
|
|
9776
11593
|
r#"{"code":"AUTHORIZATION_DENIED","message":"policy denied"}"#,
|
|
9777
11594
|
] {
|
|
9778
|
-
|
|
9779
|
-
|
|
9780
|
-
|
|
9781
|
-
);
|
|
11595
|
+
let body = api_error_body(StatusCode::FORBIDDEN, internal_message.into());
|
|
11596
|
+
assert_eq!(body["error"], "FORBIDDEN");
|
|
11597
|
+
assert_eq!(body["code"], "FORBIDDEN");
|
|
11598
|
+
assert!(body["request_id"].as_str().unwrap().starts_with("req_"));
|
|
9782
11599
|
}
|
|
9783
11600
|
}
|
|
9784
11601
|
}
|