@feltdb/core 0.7.4 → 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 +3 -1
- package/dist/application-development.d.ts.map +1 -1
- package/dist/application-development.js +16 -0
- package/dist/application-manifest.d.ts +1 -0
- package/dist/application-manifest.d.ts.map +1 -1
- package/dist/canonical-application.d.ts +1 -0
- package/dist/canonical-application.d.ts.map +1 -1
- package/dist/canonical-application.js +3 -2
- package/dist/cli/cli.js +1 -1
- package/dist/cli/commands.js +225 -42
- 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 +12 -3
- package/dist/cli/publish-engine.js +7 -4
- package/dist/cli/source-sync.js +47 -3
- package/dist/create/cli.js +10 -1
- package/dist/create/create.js +10 -12
- package/dist/create/managed-account.js +71 -7
- package/dist/create/package-versions.js +1 -1
- package/dist/create/server-source/crates/feltdb/src/application.rs +10 -0
- package/dist/create/server-source/crates/feltdb/src/lib.rs +20 -2
- package/dist/create/server-source/crates/feltdb-server/src/app_state.rs +12 -24
- package/dist/create/server-source/crates/feltdb-server/src/application_contract.rs +11 -3
- package/dist/create/server-source/crates/feltdb-server/src/authenticated_principal.rs +0 -1
- package/dist/create/server-source/crates/feltdb-server/src/certification_harness.rs +23 -22
- package/dist/create/server-source/crates/feltdb-server/src/delegation_token.rs +5 -15
- package/dist/create/server-source/crates/feltdb-server/src/durable_operations.rs +13 -28
- package/dist/create/server-source/crates/feltdb-server/src/identity.rs +79 -14
- package/dist/create/server-source/crates/feltdb-server/src/key_management.rs +28 -7
- package/dist/create/server-source/crates/feltdb-server/src/lib.rs +5 -5
- package/dist/create/server-source/crates/feltdb-server/src/main.rs +1777 -259
- 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 +183 -21
- package/dist/create/server-source/crates/feltdb-server/src/tenant_policies.rs +9 -21
- package/dist/create/server-source/crates/feltdb-server/src/transaction_idempotency.rs +5 -3
- package/dist/create/server-source/crates/feltdb-server/src/transaction_recovery.rs +9 -8
- package/dist/create/server-source/crates/feltdb-server/tests/revision_recovery_integration_test.rs +1 -4
- package/dist/create/template/default-project/feltdb.flow +5 -0
- package/dist/flowspec.d.ts +1 -0
- package/dist/flowspec.d.ts.map +1 -1
- package/dist/flowspec.js +46 -12
- 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/index-core.d.ts +2 -0
- package/dist/index-core.d.ts.map +1 -1
- package/dist/index-core.js +2 -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 +80 -4
- package/dist/proposal.d.ts.map +1 -1
- package/dist/proposal.js +132 -5
- package/dist/studio/app.d.ts +3 -1
- package/dist/studio/app.d.ts.map +1 -1
- package/dist/studio/components/AskFeltDB.d.ts +2 -1
- package/dist/studio/components/AskFeltDB.d.ts.map +1 -1
- package/dist/studio/components/ContractInspector.d.ts +7 -0
- package/dist/studio/components/ContractInspector.d.ts.map +1 -0
- package/dist/studio/components/ManagedInstancePanel.d.ts.map +1 -1
- package/dist/studio/components/ManagedInstancePanel.js +1 -5
- package/dist/studio/components/ModuleExplorer.d.ts +6 -0
- package/dist/studio/components/ModuleExplorer.d.ts.map +1 -0
- package/dist/studio/components/ProposalBrowser.d.ts +10 -1
- package/dist/studio/components/ProposalBrowser.d.ts.map +1 -1
- package/dist/studio/components/StateExplorer.d.ts +6 -7
- package/dist/studio/components/StateExplorer.d.ts.map +1 -1
- package/dist/studio/components/index.d.ts +2 -0
- package/dist/studio/components/index.d.ts.map +1 -1
- package/dist/studio/components/index.js +4 -4
- package/dist/studio/components-CNSNrmA9.js +2787 -0
- package/dist/studio/index.js +193 -147
- package/dist/studio/studio.css +1 -1
- package/dist/studio/utils/index.d.ts +2 -0
- package/dist/studio/utils/index.d.ts.map +1 -1
- package/dist/studio/utils/index.js +14 -12
- package/dist/studio/utils/managed-instance.d.ts +0 -4
- package/dist/studio/utils/managed-instance.d.ts.map +1 -1
- package/dist/studio/utils/managed-instance.js +0 -4
- package/dist/studio/utils/proposal-api.d.ts +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-DA1MYSzp.js → dist-CCOk39Uc.js} +1 -1
- package/dist/studio-app/assets/{feltdb_wasm-B-Ccgccb.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-BRrmvaKw.css → index-yazaWMUN.css} +1 -1
- package/dist/studio-app/index.html +2 -2
- package/dist/wasm/feltdb_wasm_bg.wasm +0 -0
- package/dist/workspace/local-development-authority.d.ts.map +1 -1
- package/dist/workspace/local-development-authority.js +64 -3
- package/package.json +1 -1
- package/dist/studio/components-Bm4sjhcX.js +0 -2162
- package/dist/studio-app/assets/feltdb_wasm_bg-wH3enJIA.wasm +0 -0
- package/dist/studio-app/assets/index-CEIqowCh.js +0 -42
|
@@ -30,8 +30,8 @@ use axum::{
|
|
|
30
30
|
use base64::Engine;
|
|
31
31
|
use feltdb::{
|
|
32
32
|
application::{
|
|
33
|
-
diff_manifests, validate_manifest, ActionOperation, ApplicationManifest,
|
|
34
|
-
ApplicationStore, PreviewDataMode, PreviewPolicy, RevisionStatus,
|
|
33
|
+
diff_manifests, manifest_hash, validate_manifest, ActionOperation, ApplicationManifest,
|
|
34
|
+
ApplicationRevision, ApplicationStore, PreviewDataMode, PreviewPolicy, RevisionStatus,
|
|
35
35
|
},
|
|
36
36
|
application_runtime::{
|
|
37
37
|
resolve_runtime, runtime_readiness, snapshot_state_namespace, validate_runtime,
|
|
@@ -42,12 +42,15 @@ use feltdb::{
|
|
|
42
42
|
Grant, GrantSigner, GrantStore, Subject as GrantSubject,
|
|
43
43
|
},
|
|
44
44
|
cardinality_endpoint::{CardinalityContext, CardinalityDiagnosticResponse},
|
|
45
|
-
|
|
46
|
-
|
|
45
|
+
policy_evaluation::{
|
|
46
|
+
Actor, AuthorizationState, PolicyContext, PolicyEvaluator, PolicySubject,
|
|
47
|
+
RecordAuthorizationContext,
|
|
48
|
+
},
|
|
47
49
|
state_contract::{
|
|
48
50
|
begin_read, compare_schemas, execute_query as execute_state_query, execute_transaction,
|
|
49
|
-
execute_transaction_with_collection_policies, schema_from_revision, validate_schema,
|
|
50
|
-
|
|
51
|
+
execute_transaction_with_collection_policies, schema_from_revision, validate_schema,
|
|
52
|
+
AuthorizationContext, CanonicalQuery, QueryFilter, StateSchema, TransactionOperation,
|
|
53
|
+
TransactionOperationKind, TransactionRequest,
|
|
51
54
|
},
|
|
52
55
|
sync_contract::{
|
|
53
56
|
DeviceIdentity, ReconciliationOutcome, SyncAck, SyncBatch, SyncConflict, SyncCursor,
|
|
@@ -58,7 +61,8 @@ use feltdb::{
|
|
|
58
61
|
WorkerRegistration,
|
|
59
62
|
},
|
|
60
63
|
workload::{CreateWorkload, WorkloadStore},
|
|
61
|
-
DatabaseSnapshot, FeltDb, JsonCasResult, Operation,
|
|
64
|
+
AtomicMutation, DatabaseSnapshot, FeltDb, FlowError, JsonCasResult, Operation,
|
|
65
|
+
PeerAdvertisement, PeerId, RecordPrecondition, StoredRow,
|
|
62
66
|
};
|
|
63
67
|
use feltdb_server::{
|
|
64
68
|
app_state::{AppState, BoundedQueryCursor},
|
|
@@ -110,7 +114,10 @@ impl ApiError {
|
|
|
110
114
|
/// `api_error_body` already parses a message that happens to be JSON, so
|
|
111
115
|
/// this is the existing mechanism named rather than a second one.
|
|
112
116
|
fn structured(status: StatusCode, body: Value) -> Self {
|
|
113
|
-
Self(
|
|
117
|
+
Self(
|
|
118
|
+
status,
|
|
119
|
+
serde_json::to_string(&body).unwrap_or_else(|_| body.to_string()),
|
|
120
|
+
)
|
|
114
121
|
}
|
|
115
122
|
}
|
|
116
123
|
|
|
@@ -134,13 +141,30 @@ fn api_error_body(status: StatusCode, message: String) -> Value {
|
|
|
134
141
|
_ if status.is_server_error() => "EXECUTION_FAILED",
|
|
135
142
|
_ => "REQUEST_FAILED",
|
|
136
143
|
};
|
|
137
|
-
let parsed =
|
|
138
|
-
|
|
139
|
-
let
|
|
140
|
-
|
|
144
|
+
let parsed =
|
|
145
|
+
serde_json::from_str::<Value>(&message).unwrap_or_else(|_| json!({ "message": message }));
|
|
146
|
+
let mut body = if parsed.is_object() {
|
|
147
|
+
parsed
|
|
148
|
+
} else {
|
|
149
|
+
json!({ "message": parsed })
|
|
150
|
+
};
|
|
151
|
+
let object = body
|
|
152
|
+
.as_object_mut()
|
|
153
|
+
.expect("normalized error body is an object");
|
|
154
|
+
let resolved_code = object
|
|
155
|
+
.get("code")
|
|
156
|
+
.and_then(Value::as_str)
|
|
157
|
+
.unwrap_or(code)
|
|
158
|
+
.to_string();
|
|
141
159
|
object.insert("error".into(), json!(resolved_code));
|
|
142
160
|
object.insert("code".into(), json!(resolved_code));
|
|
143
|
-
object.entry("message").or_insert_with(||
|
|
161
|
+
object.entry("message").or_insert_with(|| {
|
|
162
|
+
json!(if status == StatusCode::FORBIDDEN {
|
|
163
|
+
"request is not authorized"
|
|
164
|
+
} else {
|
|
165
|
+
status.canonical_reason().unwrap_or("request failed")
|
|
166
|
+
})
|
|
167
|
+
});
|
|
144
168
|
object.insert("request_id".into(), json!(request_id));
|
|
145
169
|
body
|
|
146
170
|
}
|
|
@@ -414,24 +438,89 @@ struct ApplicationDiscoveryRequest {
|
|
|
414
438
|
environment: String,
|
|
415
439
|
}
|
|
416
440
|
#[derive(Deserialize)]
|
|
417
|
-
struct ProposalScopeRequest {
|
|
441
|
+
struct ProposalScopeRequest {
|
|
442
|
+
application_id: String,
|
|
443
|
+
#[serde(default = "production_environment")]
|
|
444
|
+
environment: String,
|
|
445
|
+
#[serde(default)]
|
|
446
|
+
status: Option<String>,
|
|
447
|
+
#[serde(default)]
|
|
448
|
+
limit: Option<usize>,
|
|
449
|
+
#[serde(default)]
|
|
450
|
+
cursor: Option<String>,
|
|
451
|
+
}
|
|
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
|
+
}
|
|
418
483
|
#[derive(Deserialize)]
|
|
419
|
-
struct
|
|
420
|
-
|
|
421
|
-
#[serde(default
|
|
422
|
-
|
|
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
|
+
}
|
|
423
494
|
#[derive(Deserialize)]
|
|
424
|
-
struct
|
|
425
|
-
|
|
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
|
+
}
|
|
426
504
|
#[derive(Deserialize)]
|
|
427
|
-
struct
|
|
428
|
-
|
|
505
|
+
struct PreviewSimulationRequest {
|
|
506
|
+
event: String,
|
|
507
|
+
}
|
|
429
508
|
#[derive(Deserialize, Default)]
|
|
430
|
-
struct EventScopeRequest {
|
|
509
|
+
struct EventScopeRequest {
|
|
510
|
+
application_id: Option<String>,
|
|
511
|
+
}
|
|
431
512
|
#[derive(Deserialize)]
|
|
432
|
-
struct SignUpRequest {
|
|
513
|
+
struct SignUpRequest {
|
|
514
|
+
application_id: String,
|
|
515
|
+
email: String,
|
|
516
|
+
password: String,
|
|
517
|
+
display_name: Option<String>,
|
|
518
|
+
}
|
|
433
519
|
#[derive(Deserialize)]
|
|
434
|
-
struct SignInRequest {
|
|
520
|
+
struct SignInRequest {
|
|
521
|
+
email: String,
|
|
522
|
+
password: String,
|
|
523
|
+
}
|
|
435
524
|
#[derive(Deserialize)]
|
|
436
525
|
struct StateContractTarget {
|
|
437
526
|
application_id: String,
|
|
@@ -2279,7 +2368,10 @@ async fn delete_certification_fixture(
|
|
|
2279
2368
|
|| ring::constant_time::verify_slices_are_equal(expected.as_bytes(), provided.as_bytes())
|
|
2280
2369
|
.is_err()
|
|
2281
2370
|
{
|
|
2282
|
-
return Err(ApiError(
|
|
2371
|
+
return Err(ApiError(
|
|
2372
|
+
StatusCode::FORBIDDEN,
|
|
2373
|
+
"certification recovery denied".into(),
|
|
2374
|
+
));
|
|
2283
2375
|
}
|
|
2284
2376
|
let tenant_id = state
|
|
2285
2377
|
.tenancy
|
|
@@ -3907,9 +3999,8 @@ async fn commit_application_draft(
|
|
|
3907
3999
|
.commit(&tenant, &application_id, &draft_id, &principal.key_id)
|
|
3908
4000
|
.map_err(manifest_error)?;
|
|
3909
4001
|
let encoded_revision = percent_encode_path_segment(&revision.revision_id);
|
|
3910
|
-
let mut response = serde_json::to_value(revision)
|
|
3911
|
-
ApiError(StatusCode::INTERNAL_SERVER_ERROR, error.to_string())
|
|
3912
|
-
})?;
|
|
4002
|
+
let mut response = serde_json::to_value(revision)
|
|
4003
|
+
.map_err(|error| ApiError(StatusCode::INTERNAL_SERVER_ERROR, error.to_string()))?;
|
|
3913
4004
|
response
|
|
3914
4005
|
.as_object_mut()
|
|
3915
4006
|
.expect("application revisions serialize as objects")
|
|
@@ -4442,137 +4533,1246 @@ async fn get_active_application_runtime(
|
|
|
4442
4533
|
revision,
|
|
4443
4534
|
&input.environment,
|
|
4444
4535
|
)?;
|
|
4445
|
-
Ok(Json(
|
|
4446
|
-
json!({"contract":contract,"runtime":state.runtimes.active(&app.tenant_id,&application_id,revision,&input.environment)}),
|
|
4447
|
-
))
|
|
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))
|
|
4448
5750
|
}
|
|
4449
|
-
|
|
4450
|
-
|
|
4451
|
-
|
|
4452
|
-
|
|
5751
|
+
|
|
5752
|
+
async fn approve_proposal(
|
|
5753
|
+
state: State<AppState>, principal: Extension<Principal>, headers: HeaderMap,
|
|
5754
|
+
path: Path<String>, Json(input): Json<ProposalLifecycleRequest>,
|
|
4453
5755
|
) -> Result<Json<Value>, ApiError> {
|
|
4454
|
-
|
|
4455
|
-
|
|
4456
|
-
.
|
|
4457
|
-
|
|
4458
|
-
let revision = state.applications.revision(&tenant, &input.application_id, &revision_id)
|
|
4459
|
-
.ok_or(ApiError(StatusCode::NOT_FOUND, "application revision not found".into()))?;
|
|
4460
|
-
let contract_hash = revision.manifest.metadata.labels.get("feltdb.contract_hash").unwrap_or(&revision.manifest_hash);
|
|
4461
|
-
let dsl_version = revision.manifest.metadata.labels.get("feltdb.dsl_version").and_then(|value| value.parse::<u32>().ok()).unwrap_or(1);
|
|
4462
|
-
Ok(Json(json!({
|
|
4463
|
-
"application_id": input.application_id,
|
|
4464
|
-
"environment": input.environment,
|
|
4465
|
-
"version": revision.revision_number,
|
|
4466
|
-
"revision_id": revision.revision_id,
|
|
4467
|
-
"contract_hash": contract_hash,
|
|
4468
|
-
"dsl_version": dsl_version,
|
|
4469
|
-
"snapshot": {
|
|
4470
|
-
"application": { "application_id": input.application_id, "environment": input.environment },
|
|
4471
|
-
"contract_version": revision.revision_number,
|
|
4472
|
-
"contract_hash": contract_hash,
|
|
4473
|
-
"dsl_version": dsl_version,
|
|
4474
|
-
"schema": revision.manifest.collections,
|
|
4475
|
-
"indexes": revision.manifest.indexes,
|
|
4476
|
-
"policies": revision.manifest.policies,
|
|
4477
|
-
"workflows": revision.manifest.workflows,
|
|
4478
|
-
"agents": revision.manifest.agents,
|
|
4479
|
-
"capabilities": revision.manifest.capabilities,
|
|
4480
|
-
"supported_primitives": ["text","integer","number","boolean","datetime","date","time","json","uuid","decimal","money","bigint","email","url","phone","binary","file","geo","object"]
|
|
4481
|
-
}
|
|
4482
|
-
})))
|
|
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
|
|
4483
5760
|
}
|
|
4484
|
-
const PROPOSAL_COLLECTION: &str = "_feltdb_proposals";
|
|
4485
|
-
const PROPOSAL_EVENT_COLLECTION: &str = "_feltdb_proposal_events";
|
|
4486
5761
|
|
|
4487
|
-
fn
|
|
4488
|
-
|
|
4489
|
-
|
|
4490
|
-
|
|
4491
|
-
|
|
4492
|
-
|
|
4493
|
-
|
|
4494
|
-
|
|
4495
|
-
}
|
|
4496
|
-
fn proposal_scope_matches(value: &Value, application_id: &str, environment: &str) -> bool {
|
|
4497
|
-
value.get("application_id").and_then(Value::as_str) == Some(application_id)
|
|
4498
|
-
&& value.get("environment").and_then(Value::as_str) == Some(environment)
|
|
4499
|
-
}
|
|
4500
|
-
async fn create_proposal(State(state): State<AppState>, Extension(principal): Extension<Principal>, Json(input): Json<CreateProposalRequest>) -> Result<(StatusCode, Json<Value>), ApiError> {
|
|
4501
|
-
let tenant = application_scope(&state, &principal.key_id, &input.application_id, "application:write")?;
|
|
4502
|
-
let revision_id = state.applications.pointers(&input.application_id).get(&input.environment).cloned()
|
|
4503
|
-
.ok_or(ApiError(StatusCode::NOT_FOUND, "application environment has no active contract".into()))?;
|
|
4504
|
-
let revision = state.applications.revision(&tenant, &input.application_id, &revision_id)
|
|
4505
|
-
.ok_or(ApiError(StatusCode::NOT_FOUND, "application revision not found".into()))?;
|
|
4506
|
-
let contract_hash = revision.manifest.metadata.labels.get("feltdb.contract_hash").unwrap_or(&revision.manifest_hash);
|
|
4507
|
-
if input.base_contract_hash != *contract_hash { return Err(ApiError(StatusCode::CONFLICT, "STALE_PROPOSAL: base contract does not match active contract".into())); }
|
|
4508
|
-
if input.proposed_flow.trim().is_empty() || input.base_flow_hash.trim().is_empty() { return Err(ApiError(StatusCode::BAD_REQUEST, "proposed_flow and base_flow_hash are required".into())); }
|
|
4509
|
-
let id = format!("p_{}", uuid::Uuid::new_v4().simple()); let now = unix_seconds_i64();
|
|
4510
|
-
let value = json!({ "id":id, "application_id":input.application_id, "environment":input.environment,
|
|
4511
|
-
"status":"proposed", "proposal_version":1, "base_contract_version":revision.revision_number,
|
|
4512
|
-
"base_contract_hash":input.base_contract_hash, "base_flow_hash":input.base_flow_hash, "proposed_flow":input.proposed_flow,
|
|
4513
|
-
"contract_diff":[], "source_plan":{"files":[]}, "summary":input.summary, "warnings":input.warnings,
|
|
4514
|
-
"created_by":principal.key_id, "created_at":now, "expires_at":input.expires_at, "metadata":input.metadata,
|
|
4515
|
-
"parent_proposal_id":input.parent_proposal_id });
|
|
4516
|
-
insert_canonical(&state, PROPOSAL_COLLECTION, &id, value.clone())?; proposal_event(&state, &id, None, "proposed", &principal.key_id)?;
|
|
4517
|
-
Ok((StatusCode::CREATED, Json(value)))
|
|
4518
|
-
}
|
|
4519
|
-
async fn list_proposals(State(state): State<AppState>, Extension(principal): Extension<Principal>, Query(input): Query<ProposalScopeRequest>) -> Result<Json<Value>, ApiError> {
|
|
4520
|
-
application_scope(&state, &principal.key_id, &input.application_id, "application:read")?;
|
|
4521
|
-
let proposals: Vec<Value> = state.db.list_collection(PROPOSAL_COLLECTION)?.into_iter().map(|row| row.value)
|
|
4522
|
-
.filter(|value| proposal_scope_matches(value, &input.application_id, &input.environment)).collect();
|
|
4523
|
-
Ok(Json(json!({"proposals":proposals})))
|
|
4524
|
-
}
|
|
4525
|
-
async fn get_proposal(State(state): State<AppState>, Extension(principal): Extension<Principal>, Path(id): Path<String>, Query(input): Query<ProposalScopeRequest>) -> Result<Json<Value>, ApiError> {
|
|
4526
|
-
application_scope(&state, &principal.key_id, &input.application_id, "application:read")?; let value = proposal_value(&state, &id)?;
|
|
4527
|
-
if !proposal_scope_matches(&value, &input.application_id, &input.environment) { return Err(ApiError(StatusCode::NOT_FOUND, "proposal not found".into())); }
|
|
4528
|
-
Ok(Json(value))
|
|
4529
|
-
}
|
|
4530
|
-
async fn proposal_history(State(state): State<AppState>, Extension(principal): Extension<Principal>, Path(id): Path<String>, Query(input): Query<ProposalScopeRequest>) -> Result<Json<Value>, ApiError> {
|
|
4531
|
-
let _ = get_proposal(State(state.clone()), Extension(principal), Path(id.clone()), Query(input)).await?;
|
|
4532
|
-
let events: Vec<Value> = state.db.list_collection(PROPOSAL_EVENT_COLLECTION)?.into_iter().map(|row| row.value)
|
|
4533
|
-
.filter(|value| value.get("proposal_id").and_then(Value::as_str) == Some(id.as_str())).collect();
|
|
4534
|
-
Ok(Json(json!({"events":events})))
|
|
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
|
|
4535
5770
|
}
|
|
4536
|
-
fn
|
|
4537
|
-
|
|
4538
|
-
|
|
4539
|
-
|
|
4540
|
-
|
|
4541
|
-
if to == "applied" { value["applied_at"] = json!(unix_seconds_i64()); }
|
|
4542
|
-
let _ = put_canonical(state, PROPOSAL_COLLECTION, id, value.clone())?; proposal_event(state, id, Some(&from), to, actor)?; Ok(value)
|
|
4543
|
-
}
|
|
4544
|
-
async fn validate_proposal(State(state): State<AppState>, Extension(principal): Extension<Principal>, Path(id): Path<String>, Json(input): Json<ProposalActorRequest>) -> Result<Json<Value>, ApiError> {
|
|
4545
|
-
let tenant = application_scope(&state, &principal.key_id, &input.application_id, "application:write")?; let mut value = proposal_value(&state, &id)?;
|
|
4546
|
-
if !proposal_scope_matches(&value, &input.application_id, &input.environment) { return Err(ApiError(StatusCode::NOT_FOUND, "proposal not found".into())); }
|
|
4547
|
-
let revision_id = state.applications.pointers(&input.application_id).get(&input.environment).cloned().ok_or(ApiError(StatusCode::NOT_FOUND,"active contract not found".into()))?;
|
|
4548
|
-
let revision = state.applications.revision(&tenant,&input.application_id,&revision_id).ok_or(ApiError(StatusCode::NOT_FOUND,"application revision not found".into()))?;
|
|
4549
|
-
let hash = revision.manifest.metadata.labels.get("feltdb.contract_hash").unwrap_or(&revision.manifest_hash);
|
|
4550
|
-
if value.get("base_contract_hash").and_then(Value::as_str) != Some(hash) { return Err(ApiError(StatusCode::CONFLICT,"STALE_PROPOSAL".into())); }
|
|
4551
|
-
if let Some(manifest) = input.proposed_manifest {
|
|
4552
|
-
let report = validate_manifest(&manifest, &tenant, &input.application_id, Some(&revision.manifest));
|
|
4553
|
-
if !report.valid { return Err(ApiError::structured(StatusCode::UNPROCESSABLE_ENTITY, json!({"code":"INVALID_PROPOSAL","validation":report}))); }
|
|
4554
|
-
let proposed = ApplicationRevision { revision_id: format!("proposal://{id}"), application_id:input.application_id.clone(), tenant_id:tenant,
|
|
4555
|
-
revision_number:revision.revision_number + 1, parent_revision_id:Some(revision.revision_id.clone()), manifest_hash:String::new(),
|
|
4556
|
-
manifest, created_by:principal.key_id.clone(), created_at:unix_seconds_i64() as u64, status:RevisionStatus::Committed };
|
|
4557
|
-
value["contract_diff"] = serde_json::to_value(diff_manifests(Some(&revision), &proposed).changes).map_err(|e|ApiError(StatusCode::INTERNAL_SERVER_ERROR,e.to_string()))?;
|
|
4558
|
-
}
|
|
4559
|
-
value["source_plan"] = json!({"files":[{"path":"feltdb.flow","operation":"modify"},{"path":"src/feltdb/generated-contract.ts","operation":"modify"}]});
|
|
4560
|
-
Ok(Json(move_proposal(&state,&id,value,"validated",&principal.key_id)?))
|
|
4561
|
-
}
|
|
4562
|
-
async fn preview_proposal(State(state): State<AppState>, Extension(principal): Extension<Principal>, Path(id): Path<String>, Json(input): Json<ProposalActorRequest>) -> Result<Json<Value>, ApiError> {
|
|
4563
|
-
application_scope(&state,&principal.key_id,&input.application_id,"application:write")?; let value=proposal_value(&state,&id)?;
|
|
4564
|
-
if !proposal_scope_matches(&value,&input.application_id,&input.environment) { return Err(ApiError(StatusCode::NOT_FOUND,"proposal not found".into())); }
|
|
4565
|
-
Ok(Json(json!({"proposal":move_proposal(&state,&id,value,"previewed",&principal.key_id)?})))
|
|
4566
|
-
}
|
|
4567
|
-
async fn transition_proposal(State(state): State<AppState>, Extension(principal): Extension<Principal>, headers: HeaderMap, Path(id): Path<String>, Json(input): Json<ProposalStatusRequest>) -> Result<Json<Value>, ApiError> {
|
|
4568
|
-
application_scope(&state,&principal.key_id,&input.application_id,"application:write")?; let value=proposal_value(&state,&id)?;
|
|
4569
|
-
if !proposal_scope_matches(&value,&input.application_id,&input.environment) { return Err(ApiError(StatusCode::NOT_FOUND,"proposal not found".into())); }
|
|
4570
|
-
if !matches!(input.status.as_str(),"approved"|"rejected"|"expired"|"applied") { return Err(ApiError(StatusCode::BAD_REQUEST,"unsupported proposal status".into())); }
|
|
4571
|
-
if input.status == "applied" && headers.get("FeltDB-Repository-Apply").and_then(|v|v.to_str().ok()) != Some("1") { return Err(ApiError(StatusCode::FORBIDDEN,"REPOSITORY_AUTHORITY_REQUIRED".into())); }
|
|
4572
|
-
if input.status == "approved" && value.get("warnings").and_then(Value::as_array).map(|items|items.iter().any(|item|item.as_str().unwrap_or("").starts_with("Authorization "))).unwrap_or(false) && !input.approve_authorization { return Err(ApiError(StatusCode::FORBIDDEN,"AUTHORIZATION_APPROVAL_REQUIRED".into())); }
|
|
4573
|
-
Ok(Json(move_proposal(&state,&id,value,&input.status,&principal.key_id)?))
|
|
4574
|
-
}
|
|
4575
|
-
fn auth_response(identity: feltdb_server::identity::Identity, session: feltdb_server::identity::IdentitySession, token: String) -> Json<Value> {
|
|
5771
|
+
fn auth_response(
|
|
5772
|
+
identity: feltdb_server::identity::Identity,
|
|
5773
|
+
session: feltdb_server::identity::IdentitySession,
|
|
5774
|
+
token: String,
|
|
5775
|
+
) -> Json<Value> {
|
|
4576
5776
|
Json(json!({
|
|
4577
5777
|
"access_token": token,
|
|
4578
5778
|
"token_type": "Bearer",
|
|
@@ -4581,25 +5781,73 @@ fn auth_response(identity: feltdb_server::identity::Identity, session: feltdb_se
|
|
|
4581
5781
|
"session": { "session_id": session.session_id, "issued_at": session.issued_at, "expires_at": session.expires_at }
|
|
4582
5782
|
}))
|
|
4583
5783
|
}
|
|
4584
|
-
async fn auth_sign_up(
|
|
4585
|
-
|
|
4586
|
-
|
|
4587
|
-
|
|
4588
|
-
state
|
|
4589
|
-
|
|
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
|
+
));
|
|
5797
|
+
}
|
|
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),
|
|
5816
|
+
)
|
|
5817
|
+
.map_err(control_error)?;
|
|
5818
|
+
state
|
|
5819
|
+
.tenancy
|
|
5820
|
+
.register_application_actor(&identity.subject_id, &input.application_id)
|
|
5821
|
+
.map_err(control_error)?;
|
|
4590
5822
|
Ok((StatusCode::CREATED, auth_response(identity, session, token)))
|
|
4591
5823
|
}
|
|
4592
|
-
async fn auth_sign_in(
|
|
4593
|
-
|
|
5824
|
+
async fn auth_sign_in(
|
|
5825
|
+
State(state): State<AppState>,
|
|
5826
|
+
Json(input): Json<SignInRequest>,
|
|
5827
|
+
) -> Result<Json<Value>, ApiError> {
|
|
5828
|
+
let (identity, session, token) = state
|
|
5829
|
+
.identities
|
|
5830
|
+
.sign_in(&input.email, &input.password)
|
|
4594
5831
|
.map_err(|_| ApiError(StatusCode::UNAUTHORIZED, "invalid credentials".into()))?;
|
|
4595
5832
|
Ok(auth_response(identity, session, token))
|
|
4596
5833
|
}
|
|
4597
5834
|
async fn auth_session(Extension(principal): Extension<Principal>) -> Json<Value> {
|
|
4598
|
-
Json(
|
|
5835
|
+
Json(
|
|
5836
|
+
json!({ "actor": { "user_id": principal.key_id, "identity_id": principal.identity_id }, "session_id": principal.session_id }),
|
|
5837
|
+
)
|
|
4599
5838
|
}
|
|
4600
|
-
async fn auth_sign_out(
|
|
4601
|
-
|
|
4602
|
-
|
|
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(),
|
|
5846
|
+
))?;
|
|
5847
|
+
state
|
|
5848
|
+
.identities
|
|
5849
|
+
.revoke_session(&principal.key_id, &session, "user_logout")
|
|
5850
|
+
.map_err(control_error)?;
|
|
4603
5851
|
Ok(StatusCode::NO_CONTENT)
|
|
4604
5852
|
}
|
|
4605
5853
|
async fn application_openapi() -> Json<Value> {
|
|
@@ -5029,8 +6277,29 @@ async fn list_runtime_triggers(
|
|
|
5029
6277
|
Ok(Json(json!(contract.triggers)))
|
|
5030
6278
|
}
|
|
5031
6279
|
|
|
5032
|
-
fn state_policy_resource_matches(resource
|
|
5033
|
-
|
|
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
|
+
}
|
|
5034
6303
|
fn authorize_transaction_collections(
|
|
5035
6304
|
principal: &Principal,
|
|
5036
6305
|
contract: &ApplicationRuntimeContract,
|
|
@@ -5063,14 +6332,14 @@ fn state_authorization(
|
|
|
5063
6332
|
) -> AuthorizationContext {
|
|
5064
6333
|
let subject = format!("{}:{}", principal.subject_type, principal.key_id);
|
|
5065
6334
|
|
|
5066
|
-
let policy=matching_state_policy(contract,collection);
|
|
6335
|
+
let policy = matching_state_policy(contract, collection);
|
|
5067
6336
|
let policy_subject = policy.and_then(|p| {
|
|
5068
|
-
|
|
5069
|
-
|
|
5070
|
-
|
|
5071
|
-
|
|
5072
|
-
|
|
5073
|
-
|
|
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
|
+
});
|
|
5074
6343
|
|
|
5075
6344
|
// Evaluate policy if found
|
|
5076
6345
|
let capabilities = if let Some(policy) = policy_subject {
|
|
@@ -5098,7 +6367,16 @@ fn state_authorization(
|
|
|
5098
6367
|
BTreeSet::new()
|
|
5099
6368
|
}
|
|
5100
6369
|
}
|
|
5101
|
-
} 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
|
+
}) {
|
|
5102
6380
|
let capability = if operation == "read" {
|
|
5103
6381
|
"state:read"
|
|
5104
6382
|
} else {
|
|
@@ -5434,16 +6712,25 @@ async fn execute_canonical_transaction(
|
|
|
5434
6712
|
input.transaction.revision_id = contract.revision_id.clone();
|
|
5435
6713
|
input.transaction.schema_version = contract.state.schema.schema_version;
|
|
5436
6714
|
input.transaction.state_namespace = Some(contract.environment.state_namespace.clone());
|
|
5437
|
-
let collections = input
|
|
6715
|
+
let collections = input
|
|
6716
|
+
.transaction
|
|
6717
|
+
.operations
|
|
6718
|
+
.iter()
|
|
5438
6719
|
.map(|operation| operation.collection.clone())
|
|
5439
6720
|
.collect::<Vec<_>>();
|
|
5440
|
-
let (authorized, write_policies) =
|
|
6721
|
+
let (authorized, write_policies) =
|
|
6722
|
+
authorize_transaction_collections(&principal, &contract, &tenant, &collections);
|
|
6723
|
+
input.transaction.authorization = state_authorization_legacy(
|
|
5441
6724
|
&principal,
|
|
5442
|
-
&contract,
|
|
5443
6725
|
&tenant,
|
|
5444
|
-
&
|
|
6726
|
+
&input.application_id,
|
|
6727
|
+
&contract.revision_id,
|
|
6728
|
+
if authorized {
|
|
6729
|
+
"state:write"
|
|
6730
|
+
} else {
|
|
6731
|
+
"state:denied"
|
|
6732
|
+
},
|
|
5445
6733
|
);
|
|
5446
|
-
input.transaction.authorization=state_authorization_legacy(&principal,&tenant,&input.application_id,&contract.revision_id,if authorized{"state:write"}else{"state:denied"});
|
|
5447
6734
|
let result = execute_transaction_with_collection_policies(
|
|
5448
6735
|
&state.db,
|
|
5449
6736
|
&contract.state.schema,
|
|
@@ -6257,18 +7544,50 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
|
|
6257
7544
|
)
|
|
6258
7545
|
.route("/v1/schema", get(get_state_schema))
|
|
6259
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))
|
|
6260
7550
|
.route("/v1/proposals", get(list_proposals).post(create_proposal))
|
|
6261
7551
|
.route("/v1/proposals/{id}", get(get_proposal))
|
|
6262
7552
|
.route("/v1/proposals/{id}/history", get(proposal_history))
|
|
6263
|
-
.route(
|
|
6264
|
-
|
|
6265
|
-
|
|
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))
|
|
6266
7576
|
.route("/v1/auth/session", get(auth_session))
|
|
6267
7577
|
.route("/v1/auth/signout", axum::routing::post(auth_sign_out))
|
|
6268
7578
|
.route("/v1/events", get(events))
|
|
6269
|
-
.route(
|
|
6270
|
-
|
|
6271
|
-
|
|
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
|
+
)
|
|
6272
7591
|
.route("/v1/state/version", get(get_state_version))
|
|
6273
7592
|
.route("/v1/query", axum::routing::post(execute_canonical_query))
|
|
6274
7593
|
.route(
|
|
@@ -6733,7 +8052,16 @@ async fn authenticate(
|
|
|
6733
8052
|
(Some(principal), _, _) => principal,
|
|
6734
8053
|
(None, Some(session), _) => Principal {
|
|
6735
8054
|
key_id: session.subject_id,
|
|
6736
|
-
scopes: vec![
|
|
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
|
+
],
|
|
6737
8065
|
subject_type: "human".into(),
|
|
6738
8066
|
identity_id: Some(session.identity_id),
|
|
6739
8067
|
session_id: Some(session.session_id),
|
|
@@ -6964,9 +8292,7 @@ async fn health(State(state): State<AppState>) -> Json<HealthResponse<'static>>
|
|
|
6964
8292
|
})
|
|
6965
8293
|
}
|
|
6966
8294
|
|
|
6967
|
-
async fn managed_diagnostics(
|
|
6968
|
-
State(state): State<AppState>,
|
|
6969
|
-
) -> Json<ManagedDiagnosticsResponse> {
|
|
8295
|
+
async fn managed_diagnostics(State(state): State<AppState>) -> Json<ManagedDiagnosticsResponse> {
|
|
6970
8296
|
let recovery = match state.cluster.proposal().map(|value| value.phase) {
|
|
6971
8297
|
Some(ProposalPhase::Preparing | ProposalPhase::Prepared) => "recovering",
|
|
6972
8298
|
Some(ProposalPhase::Aborted) => "attention-required",
|
|
@@ -6983,8 +8309,7 @@ async fn managed_diagnostics(
|
|
|
6983
8309
|
storage_format: 1,
|
|
6984
8310
|
promoted_revision: std::env::var("FELTDB_PROMOTED_REVISION")
|
|
6985
8311
|
.unwrap_or_else(|_| "unknown".into()),
|
|
6986
|
-
environment: std::env::var("FELTDB_ENVIRONMENT")
|
|
6987
|
-
.unwrap_or_else(|_| "development".into()),
|
|
8312
|
+
environment: std::env::var("FELTDB_ENVIRONMENT").unwrap_or_else(|_| "development".into()),
|
|
6988
8313
|
recovery: recovery.into(),
|
|
6989
8314
|
uptime: format!("{uptime_seconds}s"),
|
|
6990
8315
|
})
|
|
@@ -8733,7 +10058,12 @@ async fn execute_capability(
|
|
|
8733
10058
|
Path(name): Path<String>,
|
|
8734
10059
|
Json(input): Json<Value>,
|
|
8735
10060
|
) -> Result<Json<Value>, ApiError> {
|
|
8736
|
-
if !principal.permits("capabilities:run") {
|
|
10061
|
+
if !principal.permits("capabilities:run") {
|
|
10062
|
+
return Err(ApiError(
|
|
10063
|
+
StatusCode::FORBIDDEN,
|
|
10064
|
+
"capability execution is not permitted".into(),
|
|
10065
|
+
));
|
|
10066
|
+
}
|
|
8737
10067
|
validate_segment(&name)?;
|
|
8738
10068
|
Ok(Json(execute_named_capability(&state, &name, &input)?))
|
|
8739
10069
|
}
|
|
@@ -8745,15 +10075,42 @@ async fn execute_application_capability(
|
|
|
8745
10075
|
Query(input): Query<ApplicationDiscoveryRequest>,
|
|
8746
10076
|
Json(payload): Json<Value>,
|
|
8747
10077
|
) -> Result<Json<Value>, ApiError> {
|
|
8748
|
-
let tenant = application_scope(
|
|
8749
|
-
|
|
8750
|
-
.
|
|
8751
|
-
|
|
8752
|
-
|
|
8753
|
-
|
|
8754
|
-
|
|
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
|
+
))?;
|
|
8755
10109
|
if capability.visibility != "public" {
|
|
8756
|
-
return Err(ApiError(
|
|
10110
|
+
return Err(ApiError(
|
|
10111
|
+
StatusCode::FORBIDDEN,
|
|
10112
|
+
"capability is not externally invocable".into(),
|
|
10113
|
+
));
|
|
8757
10114
|
}
|
|
8758
10115
|
Ok(Json(execute_named_capability(&state, &name, &payload)?))
|
|
8759
10116
|
}
|
|
@@ -8947,7 +10304,12 @@ async fn start_workflow_run(
|
|
|
8947
10304
|
Path(name): Path<String>,
|
|
8948
10305
|
Json(request): Json<StartRun>,
|
|
8949
10306
|
) -> Result<(StatusCode, Json<RecordResponse>), ApiError> {
|
|
8950
|
-
if !principal.permits("workflows:run") {
|
|
10307
|
+
if !principal.permits("workflows:run") {
|
|
10308
|
+
return Err(ApiError(
|
|
10309
|
+
StatusCode::FORBIDDEN,
|
|
10310
|
+
"workflow execution is not permitted".into(),
|
|
10311
|
+
));
|
|
10312
|
+
}
|
|
8951
10313
|
validate_segment(&name)?;
|
|
8952
10314
|
let definition = required_value(&state, "_flow_workflows", &name, "workflow not found")?;
|
|
8953
10315
|
let steps = definition["steps"].as_array().ok_or_else(|| {
|
|
@@ -9109,7 +10471,12 @@ async fn start_agent_run(
|
|
|
9109
10471
|
Path(name): Path<String>,
|
|
9110
10472
|
Json(request): Json<StartAgentRun>,
|
|
9111
10473
|
) -> Result<(StatusCode, Json<RecordResponse>), ApiError> {
|
|
9112
|
-
if !principal.permits("agents:run") {
|
|
10474
|
+
if !principal.permits("agents:run") {
|
|
10475
|
+
return Err(ApiError(
|
|
10476
|
+
StatusCode::FORBIDDEN,
|
|
10477
|
+
"agent execution is not permitted".into(),
|
|
10478
|
+
));
|
|
10479
|
+
}
|
|
9113
10480
|
validate_segment(&name)?;
|
|
9114
10481
|
required_value(&state, "_flow_agents", &name, "agent not found")?;
|
|
9115
10482
|
if request.goal.trim().is_empty() {
|
|
@@ -9331,10 +10698,13 @@ struct BoundedQueryPage {
|
|
|
9331
10698
|
}
|
|
9332
10699
|
|
|
9333
10700
|
fn bounded_query_error(code: &str, message: impl Into<String>) -> ApiError {
|
|
9334
|
-
ApiError::structured(
|
|
9335
|
-
|
|
9336
|
-
|
|
9337
|
-
|
|
10701
|
+
ApiError::structured(
|
|
10702
|
+
StatusCode::UNPROCESSABLE_ENTITY,
|
|
10703
|
+
json!({
|
|
10704
|
+
"code": code,
|
|
10705
|
+
"message": message.into(),
|
|
10706
|
+
}),
|
|
10707
|
+
)
|
|
9338
10708
|
}
|
|
9339
10709
|
|
|
9340
10710
|
fn bounded_query_hash(request: &BoundedQueryRequest) -> Result<String, ApiError> {
|
|
@@ -9357,7 +10727,10 @@ fn query_scalar_cmp(left: Option<&Value>, right: Option<&Value>) -> std::cmp::Or
|
|
|
9357
10727
|
(Some(Value::Null), Some(Value::Null)) => std::cmp::Ordering::Equal,
|
|
9358
10728
|
(Some(Value::Null), Some(_)) => std::cmp::Ordering::Less,
|
|
9359
10729
|
(Some(_), Some(Value::Null)) => std::cmp::Ordering::Greater,
|
|
9360
|
-
(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),
|
|
9361
10734
|
(Some(Value::String(a)), Some(Value::String(b))) => a.cmp(b),
|
|
9362
10735
|
(Some(Value::Bool(a)), Some(Value::Bool(b))) => a.cmp(b),
|
|
9363
10736
|
(Some(a), Some(b)) => a.to_string().cmp(&b.to_string()),
|
|
@@ -9366,31 +10739,60 @@ fn query_scalar_cmp(left: Option<&Value>, right: Option<&Value>) -> std::cmp::Or
|
|
|
9366
10739
|
|
|
9367
10740
|
fn condition_matches(record: &Value, condition: &BoundedQueryCondition) -> Result<bool, ApiError> {
|
|
9368
10741
|
if condition.operators.len() != 1 || condition.field.trim().is_empty() {
|
|
9369
|
-
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
|
+
));
|
|
9370
10746
|
}
|
|
9371
|
-
let (operator, expected) = condition
|
|
10747
|
+
let (operator, expected) = condition
|
|
10748
|
+
.operators
|
|
10749
|
+
.iter()
|
|
10750
|
+
.next()
|
|
10751
|
+
.expect("validated operator");
|
|
9372
10752
|
let actual = record.get(&condition.field);
|
|
9373
|
-
if operator == "eq" {
|
|
9374
|
-
|
|
9375
|
-
|
|
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
|
+
};
|
|
9376
10762
|
let comparison = query_scalar_cmp(Some(actual), Some(expected));
|
|
9377
10763
|
Ok(match operator.as_str() {
|
|
9378
10764
|
"lt" => comparison.is_lt(),
|
|
9379
10765
|
"lte" => !comparison.is_gt(),
|
|
9380
10766
|
"gt" => comparison.is_gt(),
|
|
9381
10767
|
"gte" => !comparison.is_lt(),
|
|
9382
|
-
_ =>
|
|
10768
|
+
_ => {
|
|
10769
|
+
return Err(bounded_query_error(
|
|
10770
|
+
"INVALID_QUERY",
|
|
10771
|
+
format!("unsupported where operator: {operator}"),
|
|
10772
|
+
))
|
|
10773
|
+
}
|
|
9383
10774
|
})
|
|
9384
10775
|
}
|
|
9385
10776
|
|
|
9386
|
-
fn issue_bounded_cursor(
|
|
10777
|
+
fn issue_bounded_cursor(
|
|
10778
|
+
state: &AppState,
|
|
10779
|
+
mut cursor: BoundedQueryCursor,
|
|
10780
|
+
) -> Result<String, ApiError> {
|
|
9387
10781
|
let token = uuid::Uuid::new_v4().simple().to_string();
|
|
9388
10782
|
let now = unix_seconds_i64().max(0) as u64;
|
|
9389
10783
|
cursor.created_at = now;
|
|
9390
|
-
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
|
+
})?;
|
|
9391
10790
|
cursors.retain(|_, value| now.saturating_sub(value.created_at) <= BOUNDED_CURSOR_TTL_SECONDS);
|
|
9392
10791
|
if cursors.len() >= 1024 {
|
|
9393
|
-
return Err(ApiError(
|
|
10792
|
+
return Err(ApiError(
|
|
10793
|
+
StatusCode::SERVICE_UNAVAILABLE,
|
|
10794
|
+
"query cursor capacity exhausted".into(),
|
|
10795
|
+
));
|
|
9394
10796
|
}
|
|
9395
10797
|
cursors.insert(token.clone(), cursor);
|
|
9396
10798
|
Ok(token)
|
|
@@ -9403,32 +10805,67 @@ async fn execute_bounded_query(
|
|
|
9403
10805
|
) -> Result<Json<BoundedQueryPage>, ApiError> {
|
|
9404
10806
|
validate_segment(&request.collection)?;
|
|
9405
10807
|
if request.limit == 0 || request.limit > MAX_BOUNDED_QUERY_LIMIT {
|
|
9406
|
-
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
|
+
));
|
|
9407
10812
|
}
|
|
9408
10813
|
if request.order_by.is_empty() {
|
|
9409
|
-
return Err(bounded_query_error(
|
|
10814
|
+
return Err(bounded_query_error(
|
|
10815
|
+
"INVALID_QUERY",
|
|
10816
|
+
"orderBy must contain at least one field",
|
|
10817
|
+
));
|
|
9410
10818
|
}
|
|
9411
10819
|
for order in &request.order_by {
|
|
9412
10820
|
if order.field.trim().is_empty() || !matches!(order.direction.as_str(), "asc" | "desc") {
|
|
9413
|
-
return Err(bounded_query_error(
|
|
10821
|
+
return Err(bounded_query_error(
|
|
10822
|
+
"INVALID_QUERY",
|
|
10823
|
+
"orderBy directions must be asc or desc",
|
|
10824
|
+
));
|
|
9414
10825
|
}
|
|
9415
10826
|
}
|
|
9416
10827
|
for condition in &request.conditions {
|
|
9417
|
-
if condition.operators.len() != 1
|
|
9418
|
-
||
|
|
9419
|
-
|
|
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
|
+
));
|
|
9420
10841
|
}
|
|
9421
10842
|
}
|
|
9422
10843
|
let hash = bounded_query_hash(&request)?;
|
|
9423
10844
|
let (records, position) = if let Some(token) = &request.cursor {
|
|
9424
|
-
let cursor = state
|
|
9425
|
-
.
|
|
9426
|
-
.
|
|
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()
|
|
9427
10856
|
.ok_or_else(|| bounded_query_error("INVALID_CURSOR", "cursor is invalid or expired"))?;
|
|
9428
|
-
if cursor.query_hash != hash
|
|
9429
|
-
|
|
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
|
+
));
|
|
9430
10865
|
}
|
|
9431
|
-
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
|
+
{
|
|
9432
10869
|
return Err(bounded_query_error("INVALID_CURSOR", "cursor is expired"));
|
|
9433
10870
|
}
|
|
9434
10871
|
(cursor.records, cursor.position)
|
|
@@ -9437,13 +10874,21 @@ async fn execute_bounded_query(
|
|
|
9437
10874
|
for row in state.db.list_collection(&request.collection)? {
|
|
9438
10875
|
let mut value = row.value;
|
|
9439
10876
|
if let Some(object) = value.as_object_mut() {
|
|
9440
|
-
let id = row
|
|
10877
|
+
let id = row
|
|
10878
|
+
.key
|
|
10879
|
+
.split_once(':')
|
|
10880
|
+
.map(|(_, id)| id)
|
|
10881
|
+
.unwrap_or(&row.key);
|
|
9441
10882
|
// `recordId` is authority metadata for this query surface, not
|
|
9442
10883
|
// caller-controlled document data. It is the final total-order
|
|
9443
10884
|
// tie-breaker even when a document contains a field by that name.
|
|
9444
10885
|
object.insert("recordId".into(), Value::String(id.to_string()));
|
|
9445
10886
|
}
|
|
9446
|
-
if request
|
|
10887
|
+
if request
|
|
10888
|
+
.conditions
|
|
10889
|
+
.iter()
|
|
10890
|
+
.all(|condition| condition_matches(&value, condition).unwrap_or(false))
|
|
10891
|
+
{
|
|
9447
10892
|
records.push(value);
|
|
9448
10893
|
}
|
|
9449
10894
|
}
|
|
@@ -9452,7 +10897,13 @@ async fn execute_bounded_query(
|
|
|
9452
10897
|
records.sort_by(|left, right| {
|
|
9453
10898
|
for order in &request.order_by {
|
|
9454
10899
|
let comparison = query_scalar_cmp(left.get(&order.field), right.get(&order.field));
|
|
9455
|
-
if !comparison.is_eq() {
|
|
10900
|
+
if !comparison.is_eq() {
|
|
10901
|
+
return if order.direction == "desc" {
|
|
10902
|
+
comparison.reverse()
|
|
10903
|
+
} else {
|
|
10904
|
+
comparison
|
|
10905
|
+
};
|
|
10906
|
+
}
|
|
9456
10907
|
}
|
|
9457
10908
|
query_scalar_cmp(left.get("recordId"), right.get("recordId"))
|
|
9458
10909
|
});
|
|
@@ -9461,23 +10912,37 @@ async fn execute_bounded_query(
|
|
|
9461
10912
|
let end = position.saturating_add(request.limit).min(records.len());
|
|
9462
10913
|
let page = records[position.min(records.len())..end].to_vec();
|
|
9463
10914
|
let next_cursor = if end < records.len() {
|
|
9464
|
-
Some(issue_bounded_cursor(
|
|
9465
|
-
|
|
9466
|
-
|
|
9467
|
-
|
|
9468
|
-
|
|
9469
|
-
|
|
9470
|
-
|
|
9471
|
-
|
|
9472
|
-
|
|
9473
|
-
|
|
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
|
+
}))
|
|
9474
10934
|
}
|
|
9475
10935
|
|
|
9476
10936
|
async fn list_records(
|
|
9477
10937
|
State(state): State<AppState>,
|
|
9478
10938
|
Path(collection): Path<String>,
|
|
9479
10939
|
) -> Result<Json<Vec<RecordResponse>>, ApiError> {
|
|
9480
|
-
if collection == PROPOSAL_COLLECTION || collection == PROPOSAL_EVENT_COLLECTION {
|
|
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
|
+
}
|
|
9481
10946
|
validate_segment(&collection)?;
|
|
9482
10947
|
let rows = state.db.list_collection(&collection)?;
|
|
9483
10948
|
Ok(Json(rows.into_iter().map(record_response).collect()))
|
|
@@ -9487,7 +10952,12 @@ async fn get_record(
|
|
|
9487
10952
|
State(state): State<AppState>,
|
|
9488
10953
|
Path((collection, id)): Path<(String, String)>,
|
|
9489
10954
|
) -> Result<Json<RecordResponse>, ApiError> {
|
|
9490
|
-
if collection == PROPOSAL_COLLECTION || collection == PROPOSAL_EVENT_COLLECTION {
|
|
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
|
+
}
|
|
9491
10961
|
let key = record_key(&collection, &id)?;
|
|
9492
10962
|
let value = state
|
|
9493
10963
|
.db
|
|
@@ -9708,7 +11178,11 @@ async fn commit_transaction(
|
|
|
9708
11178
|
})?;
|
|
9709
11179
|
|
|
9710
11180
|
Ok((
|
|
9711
|
-
if commit.duplicate {
|
|
11181
|
+
if commit.duplicate {
|
|
11182
|
+
StatusCode::OK
|
|
11183
|
+
} else {
|
|
11184
|
+
StatusCode::CREATED
|
|
11185
|
+
},
|
|
9712
11186
|
Json(AtomicTxResponse {
|
|
9713
11187
|
transaction_id: commit.transaction_id,
|
|
9714
11188
|
duplicate: commit.duplicate,
|
|
@@ -9724,7 +11198,12 @@ async fn create_record(
|
|
|
9724
11198
|
Path(collection): Path<String>,
|
|
9725
11199
|
Json(mut record): Json<CreateRecord>,
|
|
9726
11200
|
) -> Result<(StatusCode, Json<RecordResponse>), ApiError> {
|
|
9727
|
-
if collection == PROPOSAL_COLLECTION || collection == PROPOSAL_EVENT_COLLECTION {
|
|
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
|
+
}
|
|
9728
11207
|
validate_segment(&collection)?;
|
|
9729
11208
|
let id = record
|
|
9730
11209
|
.id
|
|
@@ -9758,7 +11237,12 @@ async fn update_record(
|
|
|
9758
11237
|
Path((collection, id)): Path<(String, String)>,
|
|
9759
11238
|
Json(changes): Json<Map<String, Value>>,
|
|
9760
11239
|
) -> Result<Json<RecordResponse>, ApiError> {
|
|
9761
|
-
if collection == PROPOSAL_COLLECTION || collection == PROPOSAL_EVENT_COLLECTION {
|
|
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
|
+
}
|
|
9762
11246
|
let key = record_key(&collection, &id)?;
|
|
9763
11247
|
let current = state
|
|
9764
11248
|
.db
|
|
@@ -9874,7 +11358,12 @@ async fn delete_record(
|
|
|
9874
11358
|
State(state): State<AppState>,
|
|
9875
11359
|
Path((collection, id)): Path<(String, String)>,
|
|
9876
11360
|
) -> Result<StatusCode, ApiError> {
|
|
9877
|
-
if collection == PROPOSAL_COLLECTION || collection == PROPOSAL_EVENT_COLLECTION {
|
|
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
|
+
}
|
|
9878
11367
|
let key = record_key(&collection, &id)?;
|
|
9879
11368
|
if state.db.get_value(&key)?.is_none() {
|
|
9880
11369
|
return Err(ApiError(
|
|
@@ -9891,8 +11380,15 @@ async fn events(
|
|
|
9891
11380
|
Extension(principal): Extension<Principal>,
|
|
9892
11381
|
Query(scope): Query<EventScopeRequest>,
|
|
9893
11382
|
) -> Result<Sse<impl futures_core::Stream<Item = Result<Event, Infallible>>>, ApiError> {
|
|
9894
|
-
if !principal.permits("events:read") {
|
|
9895
|
-
|
|
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
|
+
}
|
|
9896
11392
|
let mut receiver = state.db.subscribe_changes();
|
|
9897
11393
|
let namespace = state.namespace.clone();
|
|
9898
11394
|
let actor = principal.key_id;
|
|
@@ -9982,7 +11478,10 @@ mod authority_gate_tests {
|
|
|
9982
11478
|
use super::{api_error_body, authorize_transaction_collections, state_authorization};
|
|
9983
11479
|
use axum::http::StatusCode;
|
|
9984
11480
|
use feltdb::{
|
|
9985
|
-
application::{
|
|
11481
|
+
application::{
|
|
11482
|
+
manifest_hash, ApplicationManifest, ApplicationRevision, PolicyDefinition,
|
|
11483
|
+
RevisionStatus,
|
|
11484
|
+
},
|
|
9986
11485
|
application_runtime::{resolve_runtime, ApplicationRuntimeContract, RuntimeInventory},
|
|
9987
11486
|
};
|
|
9988
11487
|
use feltdb_server::auth::Principal;
|
|
@@ -10028,9 +11527,16 @@ mod authority_gate_tests {
|
|
|
10028
11527
|
#[test]
|
|
10029
11528
|
fn no_matching_policy_denies() {
|
|
10030
11529
|
let contract = contract(vec![]);
|
|
10031
|
-
assert!(
|
|
11530
|
+
assert!(
|
|
11531
|
+
state_authorization(&principal(), &contract, "secrets", "read")
|
|
11532
|
+
.capabilities
|
|
11533
|
+
.is_empty()
|
|
11534
|
+
);
|
|
10032
11535
|
let (allowed, _) = authorize_transaction_collections(
|
|
10033
|
-
&principal(),
|
|
11536
|
+
&principal(),
|
|
11537
|
+
&contract,
|
|
11538
|
+
"tenant",
|
|
11539
|
+
&["secrets".into()],
|
|
10034
11540
|
);
|
|
10035
11541
|
assert!(!allowed);
|
|
10036
11542
|
}
|
|
@@ -10039,7 +11545,10 @@ mod authority_gate_tests {
|
|
|
10039
11545
|
fn terminal_wildcard_policy_allows_matching_collection() {
|
|
10040
11546
|
let contract = contract(vec![allow("buzz_*")]);
|
|
10041
11547
|
let (allowed, _) = authorize_transaction_collections(
|
|
10042
|
-
&principal(),
|
|
11548
|
+
&principal(),
|
|
11549
|
+
&contract,
|
|
11550
|
+
"tenant",
|
|
11551
|
+
&["buzz_rooms".into()],
|
|
10043
11552
|
);
|
|
10044
11553
|
assert!(allowed);
|
|
10045
11554
|
}
|
|
@@ -10048,10 +11557,16 @@ mod authority_gate_tests {
|
|
|
10048
11557
|
fn multi_collection_transaction_authorizes_every_operation() {
|
|
10049
11558
|
let contract = contract(vec![allow("alpha"), allow("beta")]);
|
|
10050
11559
|
let (all_allowed, _) = authorize_transaction_collections(
|
|
10051
|
-
&principal(),
|
|
11560
|
+
&principal(),
|
|
11561
|
+
&contract,
|
|
11562
|
+
"tenant",
|
|
11563
|
+
&["alpha".into(), "beta".into()],
|
|
10052
11564
|
);
|
|
10053
11565
|
let (mixed_allowed, _) = authorize_transaction_collections(
|
|
10054
|
-
&principal(),
|
|
11566
|
+
&principal(),
|
|
11567
|
+
&contract,
|
|
11568
|
+
"tenant",
|
|
11569
|
+
&["alpha".into(), "secrets".into()],
|
|
10055
11570
|
);
|
|
10056
11571
|
assert!(all_allowed);
|
|
10057
11572
|
assert!(!mixed_allowed);
|
|
@@ -10061,7 +11576,10 @@ mod authority_gate_tests {
|
|
|
10061
11576
|
fn cross_tenant_transaction_denies() {
|
|
10062
11577
|
let contract = contract(vec![allow("alpha")]);
|
|
10063
11578
|
let (allowed, _) = authorize_transaction_collections(
|
|
10064
|
-
&principal(),
|
|
11579
|
+
&principal(),
|
|
11580
|
+
&contract,
|
|
11581
|
+
"other-tenant",
|
|
11582
|
+
&["alpha".into()],
|
|
10065
11583
|
);
|
|
10066
11584
|
assert!(!allowed);
|
|
10067
11585
|
}
|