@feltdb/core 0.7.2 → 0.7.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/application-development.d.ts +76 -0
- package/dist/application-development.d.ts.map +1 -0
- package/dist/application-development.js +137 -0
- package/dist/application-manifest.d.ts +5 -1
- package/dist/application-manifest.d.ts.map +1 -1
- package/dist/application-manifest.js +10 -3
- package/dist/canonical-application.d.ts +26 -0
- package/dist/canonical-application.d.ts.map +1 -0
- package/dist/canonical-application.js +45 -0
- package/dist/cli/ai-model-config.js +97 -0
- package/dist/cli/commands.js +485 -30
- package/dist/cli/index.js +1 -1
- package/dist/cli/proposal-client.js +54 -0
- package/dist/cli/publish-engine.js +162 -0
- package/dist/cli/source-sync.js +139 -0
- package/dist/create/cli.js +1 -1
- package/dist/create/create.js +30 -6
- package/dist/create/managed-account.js +14 -7
- package/dist/create/package-versions.js +1 -1
- package/dist/create/server-source/crates/feltdb/src/application.rs +31 -1
- package/dist/create/server-source/crates/feltdb/src/authority_failover.rs +847 -0
- package/dist/create/server-source/crates/feltdb/src/bin/feltdb_node.rs +338 -29
- package/dist/create/server-source/crates/feltdb/src/distributed_transactions.rs +72 -34
- package/dist/create/server-source/crates/feltdb/src/lib.rs +1 -0
- package/dist/create/server-source/crates/feltdb-server/src/identity.rs +46 -3
- package/dist/create/server-source/crates/feltdb-server/src/main.rs +315 -16
- package/dist/create/server-source/crates/feltdb-server/src/tenancy.rs +20 -0
- package/dist/create/template/default-project/README.md +57 -0
- package/dist/create/template/default-project/agents/activity-assistant.ts +18 -0
- package/dist/create/template/default-project/agents/project-assistant.ts +19 -0
- package/dist/create/template/default-project/capabilities/activity-summary.ts +14 -0
- package/dist/create/template/default-project/capabilities/project-search.ts +16 -0
- package/dist/create/template/default-project/capabilities/project-summary.ts +15 -0
- package/dist/create/template/default-project/feltdb.flow +169 -0
- package/dist/create/template/default-project/src/App.tsx +20 -0
- package/dist/create/template/default-project/src/context/AuthContext.tsx +27 -0
- package/dist/create/template/default-project/src/feltdb.ts +87 -0
- package/dist/create/template/default-project/src/index.tsx +14 -0
- package/dist/create/template/default-project/src/pages/Activity.tsx +7 -0
- package/dist/create/template/default-project/src/pages/Agents.tsx +15 -0
- package/dist/create/template/default-project/src/pages/Dashboard.tsx +17 -0
- package/dist/create/template/default-project/src/pages/Invitations.tsx +11 -0
- package/dist/create/template/default-project/src/pages/Projects.tsx +11 -0
- package/dist/create/template/default-project/src/pages/SignIn.tsx +8 -0
- package/dist/create/template/default-project/src/pages/SignUp.tsx +8 -0
- package/dist/create/template/default-project/src/styles-application.css +21 -0
- package/dist/create/template/default-project/src/styles.css +1 -0
- package/dist/create/template/default-project/workflows/agent-assisted-summary.ts +1 -0
- package/dist/create/template/default-project/workflows/invitation.ts +30 -0
- package/dist/create/template/default-project/workflows/project-created.ts +2 -0
- package/dist/db.d.ts +49 -1
- package/dist/db.d.ts.map +1 -1
- package/dist/db.js +77 -0
- package/dist/error-codes.d.ts +20 -1
- package/dist/error-codes.d.ts.map +1 -1
- package/dist/error-codes.js +25 -0
- package/dist/flowspec.d.ts.map +1 -1
- package/dist/flowspec.js +27 -6
- package/dist/http-db.d.ts +15 -2
- package/dist/http-db.d.ts.map +1 -1
- package/dist/http-db.js +113 -5
- package/dist/index-core.d.ts +3 -0
- package/dist/index-core.d.ts.map +1 -1
- package/dist/index-core.js +3 -0
- package/dist/proposal.d.ts +85 -0
- package/dist/proposal.d.ts.map +1 -0
- package/dist/proposal.js +105 -0
- package/dist/studio/app.d.ts +5 -1
- package/dist/studio/app.d.ts.map +1 -1
- package/dist/studio/components/ApplicationDesigner.d.ts +10 -1
- package/dist/studio/components/ApplicationDesigner.d.ts.map +1 -1
- package/dist/studio/components/AskFeltDB.d.ts +20 -0
- package/dist/studio/components/AskFeltDB.d.ts.map +1 -0
- package/dist/studio/components/ProposalBrowser.d.ts +3 -0
- package/dist/studio/components/ProposalBrowser.d.ts.map +1 -0
- 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-C5p2TfIU.js → components-Bm4sjhcX.js} +531 -192
- package/dist/studio/index.js +184 -172
- package/dist/studio/studio.css +1 -1
- package/dist/studio-app/assets/dist-DA1MYSzp.js +1 -0
- package/dist/studio-app/assets/{feltdb_wasm-DVKsw75S.js → feltdb_wasm-B-Ccgccb.js} +1 -1
- package/dist/studio-app/assets/feltdb_wasm_bg-wH3enJIA.wasm +0 -0
- package/dist/studio-app/assets/{index-C1GWyazR.css → index-BRrmvaKw.css} +1 -1
- package/dist/studio-app/assets/index-CEIqowCh.js +42 -0
- package/dist/studio-app/assets/lib-B2_7fcn7.js +69 -0
- package/dist/studio-app/assets/worker-CARZ5g7K.js +69 -0
- package/dist/studio-app/index.html +2 -2
- package/dist/wasm/feltdb_wasm_bg.wasm +0 -0
- package/dist/workspace/local-development-authority.d.ts +3 -0
- package/dist/workspace/local-development-authority.d.ts.map +1 -1
- package/dist/workspace/local-development-authority.js +62 -1
- package/package.json +1 -1
- package/dist/studio-app/assets/feltdb_wasm_bg-DNyNf0yy.wasm +0 -0
- package/dist/studio-app/assets/index-C71X92EK.js +0 -28
|
@@ -30,7 +30,8 @@ use axum::{
|
|
|
30
30
|
use base64::Engine;
|
|
31
31
|
use feltdb::{
|
|
32
32
|
application::{
|
|
33
|
-
|
|
33
|
+
diff_manifests, validate_manifest, ActionOperation, ApplicationManifest, ApplicationRevision,
|
|
34
|
+
ApplicationStore, PreviewDataMode, PreviewPolicy, RevisionStatus,
|
|
34
35
|
},
|
|
35
36
|
application_runtime::{
|
|
36
37
|
resolve_runtime, runtime_readiness, snapshot_state_namespace, validate_runtime,
|
|
@@ -121,10 +122,27 @@ impl IntoResponse for ApiError {
|
|
|
121
122
|
}
|
|
122
123
|
|
|
123
124
|
fn api_error_body(status: StatusCode, message: String) -> Value {
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
125
|
+
let request_id = format!("req_{}", uuid::Uuid::new_v4().simple());
|
|
126
|
+
let code = match status {
|
|
127
|
+
StatusCode::UNAUTHORIZED => "AUTHENTICATION_REQUIRED",
|
|
128
|
+
StatusCode::FORBIDDEN => "FORBIDDEN",
|
|
129
|
+
StatusCode::NOT_FOUND => "NOT_FOUND",
|
|
130
|
+
StatusCode::CONFLICT => "CONFLICT",
|
|
131
|
+
StatusCode::TOO_MANY_REQUESTS => "QUERY_LIMIT_EXCEEDED",
|
|
132
|
+
StatusCode::SERVICE_UNAVAILABLE => "SERVICE_UNAVAILABLE",
|
|
133
|
+
StatusCode::BAD_REQUEST | StatusCode::UNPROCESSABLE_ENTITY => "VALIDATION_ERROR",
|
|
134
|
+
_ if status.is_server_error() => "EXECUTION_FAILED",
|
|
135
|
+
_ => "REQUEST_FAILED",
|
|
136
|
+
};
|
|
137
|
+
let parsed = serde_json::from_str::<Value>(&message).unwrap_or_else(|_| json!({ "message": message }));
|
|
138
|
+
let mut body = if parsed.is_object() { parsed } else { json!({ "message": parsed }) };
|
|
139
|
+
let object = body.as_object_mut().expect("normalized error body is an object");
|
|
140
|
+
let resolved_code = object.get("code").and_then(Value::as_str).unwrap_or(code).to_string();
|
|
141
|
+
object.insert("error".into(), json!(resolved_code));
|
|
142
|
+
object.insert("code".into(), json!(resolved_code));
|
|
143
|
+
object.entry("message").or_insert_with(|| json!(if status == StatusCode::FORBIDDEN { "request is not authorized" } else { status.canonical_reason().unwrap_or("request failed") }));
|
|
144
|
+
object.insert("request_id".into(), json!(request_id));
|
|
145
|
+
body
|
|
128
146
|
}
|
|
129
147
|
|
|
130
148
|
impl From<feltdb::FlowError> for ApiError {
|
|
@@ -390,6 +408,31 @@ struct RuntimeEnvironmentRequest {
|
|
|
390
408
|
environment: String,
|
|
391
409
|
}
|
|
392
410
|
#[derive(Deserialize)]
|
|
411
|
+
struct ApplicationDiscoveryRequest {
|
|
412
|
+
application_id: String,
|
|
413
|
+
#[serde(default = "production_environment")]
|
|
414
|
+
environment: String,
|
|
415
|
+
}
|
|
416
|
+
#[derive(Deserialize)]
|
|
417
|
+
struct ProposalScopeRequest { application_id: String, #[serde(default = "production_environment")] environment: String }
|
|
418
|
+
#[derive(Deserialize)]
|
|
419
|
+
struct CreateProposalRequest { application_id: String, #[serde(default = "production_environment")] environment: String,
|
|
420
|
+
base_contract_hash: String, base_flow_hash: String, proposed_flow: String, summary: String,
|
|
421
|
+
#[serde(default)] warnings: Vec<String>, #[serde(default)] metadata: Value,
|
|
422
|
+
parent_proposal_id: Option<String>, expires_at: Option<String> }
|
|
423
|
+
#[derive(Deserialize)]
|
|
424
|
+
struct ProposalActorRequest { application_id: String, #[serde(default = "production_environment")] environment: String,
|
|
425
|
+
proposed_manifest: Option<ApplicationManifest> }
|
|
426
|
+
#[derive(Deserialize)]
|
|
427
|
+
struct ProposalStatusRequest { application_id: String, #[serde(default = "production_environment")] environment: String,
|
|
428
|
+
status: String, #[serde(default)] approve_authorization: bool }
|
|
429
|
+
#[derive(Deserialize, Default)]
|
|
430
|
+
struct EventScopeRequest { application_id: Option<String> }
|
|
431
|
+
#[derive(Deserialize)]
|
|
432
|
+
struct SignUpRequest { application_id: String, email: String, password: String, display_name: Option<String> }
|
|
433
|
+
#[derive(Deserialize)]
|
|
434
|
+
struct SignInRequest { email: String, password: String }
|
|
435
|
+
#[derive(Deserialize)]
|
|
393
436
|
struct StateContractTarget {
|
|
394
437
|
application_id: String,
|
|
395
438
|
revision_id: String,
|
|
@@ -4403,6 +4446,190 @@ async fn get_active_application_runtime(
|
|
|
4403
4446
|
json!({"contract":contract,"runtime":state.runtimes.active(&app.tenant_id,&application_id,revision,&input.environment)}),
|
|
4404
4447
|
))
|
|
4405
4448
|
}
|
|
4449
|
+
async fn discover_application(
|
|
4450
|
+
State(state): State<AppState>,
|
|
4451
|
+
Extension(principal): Extension<Principal>,
|
|
4452
|
+
Query(input): Query<ApplicationDiscoveryRequest>,
|
|
4453
|
+
) -> Result<Json<Value>, ApiError> {
|
|
4454
|
+
let tenant = application_scope(&state, &principal.key_id, &input.application_id, "application:read")?;
|
|
4455
|
+
let revision_id = state.applications.pointers(&input.application_id)
|
|
4456
|
+
.get(&input.environment).cloned()
|
|
4457
|
+
.ok_or(ApiError(StatusCode::NOT_FOUND, "application environment has no active contract".into()))?;
|
|
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
|
+
})))
|
|
4483
|
+
}
|
|
4484
|
+
const PROPOSAL_COLLECTION: &str = "_feltdb_proposals";
|
|
4485
|
+
const PROPOSAL_EVENT_COLLECTION: &str = "_feltdb_proposal_events";
|
|
4486
|
+
|
|
4487
|
+
fn proposal_event(state: &AppState, proposal_id: &str, from: Option<&str>, to: &str, actor: &str) -> Result<(), ApiError> {
|
|
4488
|
+
let id = format!("pe_{}", uuid::Uuid::new_v4().simple());
|
|
4489
|
+
insert_canonical(state, PROPOSAL_EVENT_COLLECTION, &id, json!({ "id":id, "proposal_id":proposal_id,
|
|
4490
|
+
"from_status":from, "to_status":to, "actor":actor, "created_at":unix_seconds_i64(), "metadata":{} }))?;
|
|
4491
|
+
Ok(())
|
|
4492
|
+
}
|
|
4493
|
+
fn proposal_value(state: &AppState, id: &str) -> Result<Value, ApiError> {
|
|
4494
|
+
required_value(state, PROPOSAL_COLLECTION, id, "proposal not found")
|
|
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})))
|
|
4535
|
+
}
|
|
4536
|
+
fn move_proposal(state: &AppState, id: &str, mut value: Value, to: &str, actor: &str) -> Result<Value, ApiError> {
|
|
4537
|
+
let from = value.get("status").and_then(Value::as_str).unwrap_or("").to_string();
|
|
4538
|
+
let allowed = matches!((from.as_str(),to), ("proposed","validated")|("proposed","rejected")|("validated","previewed")|("validated","rejected")|("previewed","approved")|("previewed","rejected")|("approved","applied")|("approved","expired"));
|
|
4539
|
+
if !allowed { return Err(ApiError(StatusCode::CONFLICT, format!("INVALID_PROPOSAL_TRANSITION: {from} -> {to}"))); }
|
|
4540
|
+
value["status"] = json!(to); if to == "approved" { value["approved_by"] = json!(actor); value["approved_at"] = json!(unix_seconds_i64()); }
|
|
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> {
|
|
4576
|
+
Json(json!({
|
|
4577
|
+
"access_token": token,
|
|
4578
|
+
"token_type": "Bearer",
|
|
4579
|
+
"expires_at": session.expires_at,
|
|
4580
|
+
"actor": { "user_id": identity.subject_id, "identity_id": identity.identity_id, "email": identity.email, "display_name": identity.display_name },
|
|
4581
|
+
"session": { "session_id": session.session_id, "issued_at": session.issued_at, "expires_at": session.expires_at }
|
|
4582
|
+
}))
|
|
4583
|
+
}
|
|
4584
|
+
async fn auth_sign_up(State(state): State<AppState>, Json(input): Json<SignUpRequest>) -> Result<(StatusCode, Json<Value>), ApiError> {
|
|
4585
|
+
if state.tenancy.application_tenant(&input.application_id).is_none() { return Err(ApiError(StatusCode::NOT_FOUND, "application not found".into())); }
|
|
4586
|
+
let (identity, session, token) = state.identities.sign_up(&input.email, &input.password, input.display_name)
|
|
4587
|
+
.map_err(|error| ApiError(if error == "identity already exists" { StatusCode::CONFLICT } else { StatusCode::BAD_REQUEST }, error))?;
|
|
4588
|
+
state.tenancy.register_platform_user(&identity.subject_id, identity.email.as_deref().unwrap_or(&identity.identity_id)).map_err(control_error)?;
|
|
4589
|
+
state.tenancy.register_application_actor(&identity.subject_id, &input.application_id).map_err(control_error)?;
|
|
4590
|
+
Ok((StatusCode::CREATED, auth_response(identity, session, token)))
|
|
4591
|
+
}
|
|
4592
|
+
async fn auth_sign_in(State(state): State<AppState>, Json(input): Json<SignInRequest>) -> Result<Json<Value>, ApiError> {
|
|
4593
|
+
let (identity, session, token) = state.identities.sign_in(&input.email, &input.password)
|
|
4594
|
+
.map_err(|_| ApiError(StatusCode::UNAUTHORIZED, "invalid credentials".into()))?;
|
|
4595
|
+
Ok(auth_response(identity, session, token))
|
|
4596
|
+
}
|
|
4597
|
+
async fn auth_session(Extension(principal): Extension<Principal>) -> Json<Value> {
|
|
4598
|
+
Json(json!({ "actor": { "user_id": principal.key_id, "identity_id": principal.identity_id }, "session_id": principal.session_id }))
|
|
4599
|
+
}
|
|
4600
|
+
async fn auth_sign_out(State(state): State<AppState>, Extension(principal): Extension<Principal>) -> Result<StatusCode, ApiError> {
|
|
4601
|
+
let session = principal.session_id.ok_or(ApiError(StatusCode::BAD_REQUEST, "request is not an actor session".into()))?;
|
|
4602
|
+
state.identities.revoke_session(&principal.key_id, &session, "user_logout").map_err(control_error)?;
|
|
4603
|
+
Ok(StatusCode::NO_CONTENT)
|
|
4604
|
+
}
|
|
4605
|
+
async fn application_openapi() -> Json<Value> {
|
|
4606
|
+
Json(json!({
|
|
4607
|
+
"openapi": "3.1.0",
|
|
4608
|
+
"info": { "title": "FeltDB Application Service", "version": "1.0.0" },
|
|
4609
|
+
"servers": [{ "url": "/v1" }],
|
|
4610
|
+
"components": {
|
|
4611
|
+
"securitySchemes": { "bearer": { "type": "http", "scheme": "bearer" } },
|
|
4612
|
+
"schemas": {
|
|
4613
|
+
"Error": { "type": "object", "required": ["code", "message", "request_id"], "properties": {
|
|
4614
|
+
"code": { "type": "string" }, "message": { "type": "string" }, "request_id": { "type": "string" }
|
|
4615
|
+
}},
|
|
4616
|
+
"ApplicationEvent": { "type": "object", "required": ["event_id", "type", "resource", "resource_id", "actor", "timestamp", "payload"] }
|
|
4617
|
+
}
|
|
4618
|
+
},
|
|
4619
|
+
"security": [{ "bearer": [] }],
|
|
4620
|
+
"paths": {
|
|
4621
|
+
"/health": { "get": { "security": [], "responses": { "200": { "description": "Runtime health" } } } },
|
|
4622
|
+
"/application": { "get": { "responses": { "200": { "description": "Active application contract identity" } } } },
|
|
4623
|
+
"/schema": { "get": { "responses": { "200": { "description": "Active application schema" } } } },
|
|
4624
|
+
"/query": { "post": { "responses": { "200": { "description": "Bounded authorized query" } } } },
|
|
4625
|
+
"/transactions": { "post": { "responses": { "200": { "description": "Atomic idempotent mutation" } } } },
|
|
4626
|
+
"/events": { "get": { "responses": { "200": { "description": "Application event stream" } } } },
|
|
4627
|
+
"/workflows/{name}/run": { "post": { "responses": { "201": { "description": "Workflow execution" } } } },
|
|
4628
|
+
"/agents/{name}/run": { "post": { "responses": { "201": { "description": "Agent execution" } } } },
|
|
4629
|
+
"/capabilities/{name}/run": { "post": { "responses": { "200": { "description": "Public capability result" } } } }
|
|
4630
|
+
}
|
|
4631
|
+
}))
|
|
4632
|
+
}
|
|
4406
4633
|
async fn get_revision_runtime(
|
|
4407
4634
|
State(state): State<AppState>,
|
|
4408
4635
|
Extension(principal): Extension<Principal>,
|
|
@@ -6029,6 +6256,19 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
|
|
6029
6256
|
axum::routing::post(validate_state_schema),
|
|
6030
6257
|
)
|
|
6031
6258
|
.route("/v1/schema", get(get_state_schema))
|
|
6259
|
+
.route("/v1/application", get(discover_application))
|
|
6260
|
+
.route("/v1/proposals", get(list_proposals).post(create_proposal))
|
|
6261
|
+
.route("/v1/proposals/{id}", get(get_proposal))
|
|
6262
|
+
.route("/v1/proposals/{id}/history", get(proposal_history))
|
|
6263
|
+
.route("/v1/proposals/{id}/validate", axum::routing::post(validate_proposal))
|
|
6264
|
+
.route("/v1/proposals/{id}/preview", axum::routing::post(preview_proposal))
|
|
6265
|
+
.route("/v1/proposals/{id}/status", axum::routing::patch(transition_proposal))
|
|
6266
|
+
.route("/v1/auth/session", get(auth_session))
|
|
6267
|
+
.route("/v1/auth/signout", axum::routing::post(auth_sign_out))
|
|
6268
|
+
.route("/v1/events", get(events))
|
|
6269
|
+
.route("/v1/workflows/{name}/run", axum::routing::post(start_workflow_run))
|
|
6270
|
+
.route("/v1/agents/{name}/run", axum::routing::post(start_agent_run))
|
|
6271
|
+
.route("/v1/capabilities/{name}/run", axum::routing::post(execute_application_capability))
|
|
6032
6272
|
.route("/v1/state/version", get(get_state_version))
|
|
6033
6273
|
.route("/v1/query", axum::routing::post(execute_canonical_query))
|
|
6034
6274
|
.route(
|
|
@@ -6393,6 +6633,10 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
|
|
6393
6633
|
|
|
6394
6634
|
let mut app = Router::new()
|
|
6395
6635
|
.route("/health", get(health))
|
|
6636
|
+
.route("/v1/health", get(health))
|
|
6637
|
+
.route("/v1/openapi.json", get(application_openapi))
|
|
6638
|
+
.route("/v1/auth/signup", axum::routing::post(auth_sign_up))
|
|
6639
|
+
.route("/v1/auth/signin", axum::routing::post(auth_sign_in))
|
|
6396
6640
|
.route("/ready", get(readiness))
|
|
6397
6641
|
.route("/runtime", get(runtime))
|
|
6398
6642
|
.route("/metrics", get(network_metrics))
|
|
@@ -6422,6 +6666,7 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
|
|
6422
6666
|
axum::http::header::CONTENT_TYPE,
|
|
6423
6667
|
axum::http::HeaderName::from_static("feltdb-protocol"),
|
|
6424
6668
|
axum::http::HeaderName::from_static("if-version"),
|
|
6669
|
+
axum::http::HeaderName::from_static("idempotency-key"),
|
|
6425
6670
|
]),
|
|
6426
6671
|
);
|
|
6427
6672
|
}
|
|
@@ -6472,6 +6717,7 @@ async fn authenticate(
|
|
|
6472
6717
|
.and_then(|header| header.to_str().ok())
|
|
6473
6718
|
.and_then(|header| header.strip_prefix("Bearer "));
|
|
6474
6719
|
let machine = token.and_then(|token| state.keys.authenticate(token, &state.namespace));
|
|
6720
|
+
let actor = token.and_then(|token| state.identities.authenticate_session(token));
|
|
6475
6721
|
let human = request
|
|
6476
6722
|
.headers()
|
|
6477
6723
|
.get(COOKIE)
|
|
@@ -6483,9 +6729,16 @@ async fn authenticate(
|
|
|
6483
6729
|
.find_map(|cookie| cookie.strip_prefix("feltdb_session="))
|
|
6484
6730
|
})
|
|
6485
6731
|
.and_then(|token| state.sessions.verify(token));
|
|
6486
|
-
match (machine, human) {
|
|
6487
|
-
(Some(principal), _) => principal,
|
|
6488
|
-
(None, Some(session)) => {
|
|
6732
|
+
match (machine, actor, human) {
|
|
6733
|
+
(Some(principal), _, _) => principal,
|
|
6734
|
+
(None, Some(session), _) => Principal {
|
|
6735
|
+
key_id: session.subject_id,
|
|
6736
|
+
scopes: vec!["application:read".into(), "application:revision:read".into(), "state:read".into(), "state:write".into(), "events:read".into(), "workflows:run".into(), "agents:run".into(), "capabilities:run".into()],
|
|
6737
|
+
subject_type: "human".into(),
|
|
6738
|
+
identity_id: Some(session.identity_id),
|
|
6739
|
+
session_id: Some(session.session_id),
|
|
6740
|
+
},
|
|
6741
|
+
(None, None, Some(session)) => {
|
|
6489
6742
|
if state
|
|
6490
6743
|
.tenancy
|
|
6491
6744
|
.register_platform_user(&session.user_id, &session.identity)
|
|
@@ -6503,7 +6756,7 @@ async fn authenticate(
|
|
|
6503
6756
|
session_id: Some(session.id),
|
|
6504
6757
|
}
|
|
6505
6758
|
}
|
|
6506
|
-
(None, None) => {
|
|
6759
|
+
(None, None, None) => {
|
|
6507
6760
|
state.metrics.authentication_failure();
|
|
6508
6761
|
audit(
|
|
6509
6762
|
&state,
|
|
@@ -8476,13 +8729,35 @@ async fn define_capability(
|
|
|
8476
8729
|
|
|
8477
8730
|
async fn execute_capability(
|
|
8478
8731
|
State(state): State<AppState>,
|
|
8732
|
+
Extension(principal): Extension<Principal>,
|
|
8479
8733
|
Path(name): Path<String>,
|
|
8480
8734
|
Json(input): Json<Value>,
|
|
8481
8735
|
) -> Result<Json<Value>, ApiError> {
|
|
8736
|
+
if !principal.permits("capabilities:run") { return Err(ApiError(StatusCode::FORBIDDEN, "capability execution is not permitted".into())); }
|
|
8482
8737
|
validate_segment(&name)?;
|
|
8483
8738
|
Ok(Json(execute_named_capability(&state, &name, &input)?))
|
|
8484
8739
|
}
|
|
8485
8740
|
|
|
8741
|
+
async fn execute_application_capability(
|
|
8742
|
+
State(state): State<AppState>,
|
|
8743
|
+
Extension(principal): Extension<Principal>,
|
|
8744
|
+
Path(name): Path<String>,
|
|
8745
|
+
Query(input): Query<ApplicationDiscoveryRequest>,
|
|
8746
|
+
Json(payload): Json<Value>,
|
|
8747
|
+
) -> Result<Json<Value>, ApiError> {
|
|
8748
|
+
let tenant = application_scope(&state, &principal.key_id, &input.application_id, "capabilities:run")?;
|
|
8749
|
+
let revision_id = state.applications.pointers(&input.application_id).get(&input.environment).cloned()
|
|
8750
|
+
.ok_or(ApiError(StatusCode::NOT_FOUND, "application environment has no active contract".into()))?;
|
|
8751
|
+
let revision = state.applications.revision(&tenant, &input.application_id, &revision_id)
|
|
8752
|
+
.ok_or(ApiError(StatusCode::NOT_FOUND, "application revision not found".into()))?;
|
|
8753
|
+
let capability = revision.manifest.capabilities.iter().find(|value| value.name == name)
|
|
8754
|
+
.ok_or(ApiError(StatusCode::NOT_FOUND, "capability not found".into()))?;
|
|
8755
|
+
if capability.visibility != "public" {
|
|
8756
|
+
return Err(ApiError(StatusCode::FORBIDDEN, "capability is not externally invocable".into()));
|
|
8757
|
+
}
|
|
8758
|
+
Ok(Json(execute_named_capability(&state, &name, &payload)?))
|
|
8759
|
+
}
|
|
8760
|
+
|
|
8486
8761
|
fn validate_capability_program(program: &CapabilityProgram) -> Result<(), ApiError> {
|
|
8487
8762
|
if program.steps.is_empty() || program.steps.len() > 16 {
|
|
8488
8763
|
return Err(ApiError(
|
|
@@ -8668,9 +8943,11 @@ async fn define_workflow(
|
|
|
8668
8943
|
|
|
8669
8944
|
async fn start_workflow_run(
|
|
8670
8945
|
State(state): State<AppState>,
|
|
8946
|
+
Extension(principal): Extension<Principal>,
|
|
8671
8947
|
Path(name): Path<String>,
|
|
8672
8948
|
Json(request): Json<StartRun>,
|
|
8673
8949
|
) -> Result<(StatusCode, Json<RecordResponse>), ApiError> {
|
|
8950
|
+
if !principal.permits("workflows:run") { return Err(ApiError(StatusCode::FORBIDDEN, "workflow execution is not permitted".into())); }
|
|
8674
8951
|
validate_segment(&name)?;
|
|
8675
8952
|
let definition = required_value(&state, "_flow_workflows", &name, "workflow not found")?;
|
|
8676
8953
|
let steps = definition["steps"].as_array().ok_or_else(|| {
|
|
@@ -8828,9 +9105,11 @@ async fn define_agent(
|
|
|
8828
9105
|
|
|
8829
9106
|
async fn start_agent_run(
|
|
8830
9107
|
State(state): State<AppState>,
|
|
9108
|
+
Extension(principal): Extension<Principal>,
|
|
8831
9109
|
Path(name): Path<String>,
|
|
8832
9110
|
Json(request): Json<StartAgentRun>,
|
|
8833
9111
|
) -> Result<(StatusCode, Json<RecordResponse>), ApiError> {
|
|
9112
|
+
if !principal.permits("agents:run") { return Err(ApiError(StatusCode::FORBIDDEN, "agent execution is not permitted".into())); }
|
|
8834
9113
|
validate_segment(&name)?;
|
|
8835
9114
|
required_value(&state, "_flow_agents", &name, "agent not found")?;
|
|
8836
9115
|
if request.goal.trim().is_empty() {
|
|
@@ -9198,6 +9477,7 @@ async fn list_records(
|
|
|
9198
9477
|
State(state): State<AppState>,
|
|
9199
9478
|
Path(collection): Path<String>,
|
|
9200
9479
|
) -> Result<Json<Vec<RecordResponse>>, ApiError> {
|
|
9480
|
+
if collection == PROPOSAL_COLLECTION || collection == PROPOSAL_EVENT_COLLECTION { return Err(ApiError(StatusCode::FORBIDDEN,"system collection requires proposal service".into())); }
|
|
9201
9481
|
validate_segment(&collection)?;
|
|
9202
9482
|
let rows = state.db.list_collection(&collection)?;
|
|
9203
9483
|
Ok(Json(rows.into_iter().map(record_response).collect()))
|
|
@@ -9207,6 +9487,7 @@ async fn get_record(
|
|
|
9207
9487
|
State(state): State<AppState>,
|
|
9208
9488
|
Path((collection, id)): Path<(String, String)>,
|
|
9209
9489
|
) -> Result<Json<RecordResponse>, ApiError> {
|
|
9490
|
+
if collection == PROPOSAL_COLLECTION || collection == PROPOSAL_EVENT_COLLECTION { return Err(ApiError(StatusCode::FORBIDDEN,"system collection requires proposal service".into())); }
|
|
9210
9491
|
let key = record_key(&collection, &id)?;
|
|
9211
9492
|
let value = state
|
|
9212
9493
|
.db
|
|
@@ -9443,6 +9724,7 @@ async fn create_record(
|
|
|
9443
9724
|
Path(collection): Path<String>,
|
|
9444
9725
|
Json(mut record): Json<CreateRecord>,
|
|
9445
9726
|
) -> Result<(StatusCode, Json<RecordResponse>), ApiError> {
|
|
9727
|
+
if collection == PROPOSAL_COLLECTION || collection == PROPOSAL_EVENT_COLLECTION { return Err(ApiError(StatusCode::FORBIDDEN,"system collection requires proposal service".into())); }
|
|
9446
9728
|
validate_segment(&collection)?;
|
|
9447
9729
|
let id = record
|
|
9448
9730
|
.id
|
|
@@ -9476,6 +9758,7 @@ async fn update_record(
|
|
|
9476
9758
|
Path((collection, id)): Path<(String, String)>,
|
|
9477
9759
|
Json(changes): Json<Map<String, Value>>,
|
|
9478
9760
|
) -> Result<Json<RecordResponse>, ApiError> {
|
|
9761
|
+
if collection == PROPOSAL_COLLECTION || collection == PROPOSAL_EVENT_COLLECTION { return Err(ApiError(StatusCode::FORBIDDEN,"system collection requires proposal service".into())); }
|
|
9479
9762
|
let key = record_key(&collection, &id)?;
|
|
9480
9763
|
let current = state
|
|
9481
9764
|
.db
|
|
@@ -9591,6 +9874,7 @@ async fn delete_record(
|
|
|
9591
9874
|
State(state): State<AppState>,
|
|
9592
9875
|
Path((collection, id)): Path<(String, String)>,
|
|
9593
9876
|
) -> Result<StatusCode, ApiError> {
|
|
9877
|
+
if collection == PROPOSAL_COLLECTION || collection == PROPOSAL_EVENT_COLLECTION { return Err(ApiError(StatusCode::FORBIDDEN,"system collection requires proposal service".into())); }
|
|
9594
9878
|
let key = record_key(&collection, &id)?;
|
|
9595
9879
|
if state.db.get_value(&key)?.is_none() {
|
|
9596
9880
|
return Err(ApiError(
|
|
@@ -9604,14 +9888,29 @@ async fn delete_record(
|
|
|
9604
9888
|
|
|
9605
9889
|
async fn events(
|
|
9606
9890
|
State(state): State<AppState>,
|
|
9607
|
-
)
|
|
9891
|
+
Extension(principal): Extension<Principal>,
|
|
9892
|
+
Query(scope): Query<EventScopeRequest>,
|
|
9893
|
+
) -> Result<Sse<impl futures_core::Stream<Item = Result<Event, Infallible>>>, ApiError> {
|
|
9894
|
+
if !principal.permits("events:read") { return Err(ApiError(StatusCode::FORBIDDEN, "event access is not permitted".into())); }
|
|
9895
|
+
if let Some(application_id) = scope.application_id { application_scope(&state, &principal.key_id, &application_id, "events:read")?; }
|
|
9608
9896
|
let mut receiver = state.db.subscribe_changes();
|
|
9609
9897
|
let namespace = state.namespace.clone();
|
|
9898
|
+
let actor = principal.key_id;
|
|
9610
9899
|
let stream = async_stream::stream! {
|
|
9611
9900
|
loop {
|
|
9612
9901
|
match receiver.recv().await {
|
|
9613
9902
|
Ok(change) => {
|
|
9614
|
-
let
|
|
9903
|
+
let resource = change.capability.clone();
|
|
9904
|
+
let resource_id = change.key.split_once(':').map(|(_, id)| id).unwrap_or(&change.key);
|
|
9905
|
+
let data = json!({
|
|
9906
|
+
"event_id": uuid::Uuid::new_v4().to_string(),
|
|
9907
|
+
"type": "state.changed",
|
|
9908
|
+
"resource": resource,
|
|
9909
|
+
"resource_id": resource_id,
|
|
9910
|
+
"actor": actor,
|
|
9911
|
+
"timestamp": unix_seconds_i64().max(0) as u64,
|
|
9912
|
+
"payload": { "operation": "mutation", "namespace": namespace.as_ref() }
|
|
9913
|
+
});
|
|
9615
9914
|
yield Ok(Event::default().event("state.changed").json_data(data).expect("serializable event"));
|
|
9616
9915
|
}
|
|
9617
9916
|
Err(tokio::sync::broadcast::error::RecvError::Lagged(skipped)) => {
|
|
@@ -9621,7 +9920,7 @@ async fn events(
|
|
|
9621
9920
|
}
|
|
9622
9921
|
}
|
|
9623
9922
|
};
|
|
9624
|
-
Sse::new(stream).keep_alive(KeepAlive::new().interval(Duration::from_secs(15)))
|
|
9923
|
+
Ok(Sse::new(stream).keep_alive(KeepAlive::new().interval(Duration::from_secs(15))))
|
|
9625
9924
|
}
|
|
9626
9925
|
|
|
9627
9926
|
fn record_response(row: StoredRow) -> RecordResponse {
|
|
@@ -9775,10 +10074,10 @@ mod authority_gate_tests {
|
|
|
9775
10074
|
"missing scope: state:read",
|
|
9776
10075
|
r#"{"code":"AUTHORIZATION_DENIED","message":"policy denied"}"#,
|
|
9777
10076
|
] {
|
|
9778
|
-
|
|
9779
|
-
|
|
9780
|
-
|
|
9781
|
-
);
|
|
10077
|
+
let body = api_error_body(StatusCode::FORBIDDEN, internal_message.into());
|
|
10078
|
+
assert_eq!(body["error"], "FORBIDDEN");
|
|
10079
|
+
assert_eq!(body["code"], "FORBIDDEN");
|
|
10080
|
+
assert!(body["request_id"].as_str().unwrap().starts_with("req_"));
|
|
9782
10081
|
}
|
|
9783
10082
|
}
|
|
9784
10083
|
}
|
|
@@ -241,6 +241,14 @@ pub fn application_role_grants() -> Vec<RoleGrant> {
|
|
|
241
241
|
"deployment:execute".into(),
|
|
242
242
|
],
|
|
243
243
|
},
|
|
244
|
+
RoleGrant {
|
|
245
|
+
role: "actor".into(),
|
|
246
|
+
capabilities: vec![
|
|
247
|
+
"application:read".into(), "application:revision:read".into(),
|
|
248
|
+
"state:read".into(), "state:write".into(), "events:read".into(),
|
|
249
|
+
"workflows:run".into(), "agents:run".into(), "capabilities:run".into(),
|
|
250
|
+
],
|
|
251
|
+
},
|
|
244
252
|
RoleGrant {
|
|
245
253
|
role: "viewer".into(),
|
|
246
254
|
capabilities: vec![
|
|
@@ -271,6 +279,18 @@ impl TenancyStore {
|
|
|
271
279
|
records: Arc::new(RwLock::new(records)),
|
|
272
280
|
})
|
|
273
281
|
}
|
|
282
|
+
pub fn register_application_actor(&self, user_id: &str, application_id: &str) -> Result<(), String> {
|
|
283
|
+
let mut records = self.records.write().map_err(|_| "tenancy store lock poisoned")?;
|
|
284
|
+
if !records.applications.iter().any(|application| application.id == application_id && application.status == "active") {
|
|
285
|
+
return Err("application not found".into());
|
|
286
|
+
}
|
|
287
|
+
if !records.application_memberships.iter().any(|membership| membership.application_id == application_id && membership.user_id == user_id && membership.status == MembershipStatus::Active) {
|
|
288
|
+
let timestamp = now();
|
|
289
|
+
records.application_memberships.push(ApplicationMembership { application_id: application_id.into(), user_id: user_id.into(), role: "actor".into(), status: MembershipStatus::Active, created_at: timestamp, updated_at: timestamp });
|
|
290
|
+
self.persist(&records)?;
|
|
291
|
+
}
|
|
292
|
+
Ok(())
|
|
293
|
+
}
|
|
274
294
|
fn persist(&self, records: &PlatformRecords) -> Result<(), String> {
|
|
275
295
|
if let Some(parent) = self.path.parent() {
|
|
276
296
|
fs::create_dir_all(parent).map_err(|error| error.to_string())?;
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
# My FeltDB App
|
|
2
|
+
|
|
3
|
+
This small application demonstrates FeltDB's complete programming model:
|
|
4
|
+
|
|
5
|
+
- Authentication and authorization
|
|
6
|
+
- Multi-tenant application state
|
|
7
|
+
- Atomic workflows
|
|
8
|
+
- Agents with bounded capabilities
|
|
9
|
+
- Reactive UI through `@feltdb/core/react`
|
|
10
|
+
- Shared inspection through FeltDB Studio
|
|
11
|
+
|
|
12
|
+
## Run it
|
|
13
|
+
|
|
14
|
+
```bash
|
|
15
|
+
npm install
|
|
16
|
+
npm run feltdb:dev
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
In a second terminal:
|
|
20
|
+
|
|
21
|
+
```bash
|
|
22
|
+
npm run dev
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
Then sign up, create a project, invite an existing user, and run an assistant. Open the same state in Studio:
|
|
26
|
+
|
|
27
|
+
## Choose an AI model
|
|
28
|
+
|
|
29
|
+
`npm run feltdb:ai` uses private browser-local WebLLM by default. Use
|
|
30
|
+
`feltdb ai model set --provider local-webllm --model <model-id>` for a larger
|
|
31
|
+
local model, or configure an OpenAI-compatible model with a credential supplied
|
|
32
|
+
through `--api-key-stdin`. `feltdb ai model show` displays the active choice.
|
|
33
|
+
Hosted credentials remain in the operating-system vault and are never exposed
|
|
34
|
+
to the application or Studio.
|
|
35
|
+
|
|
36
|
+
```bash
|
|
37
|
+
npm run feltdb:studio
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
## How the example fits together
|
|
41
|
+
|
|
42
|
+
- `feltdb.flow` defines state, authorization, workflows, capabilities, and agents.
|
|
43
|
+
- `workflows/` implements project creation, invitation lifecycle, and agent-assisted summary examples.
|
|
44
|
+
- `capabilities/` contains the narrow read and summary operations available to assistants.
|
|
45
|
+
- `agents/` registers the Project and Activity assistants and persists their execution lifecycle.
|
|
46
|
+
- `src/` contains the authenticated React application.
|
|
47
|
+
|
|
48
|
+
These folders make the concepts easy to find; they are not mandatory application architecture. Agents and workflows use the same FeltDB collections as the UI, so they do not bypass `authenticated`, `member`, `owner`, or `self(user)` policies.
|
|
49
|
+
|
|
50
|
+
For managed FeltDB, generate with `--runtime managed`; the model and application remain the same while the runtime configuration changes.
|
|
51
|
+
|
|
52
|
+
Preview and publish the same definition to its configured cloud environment:
|
|
53
|
+
|
|
54
|
+
```bash
|
|
55
|
+
npm run feltdb:publish -- --dry-run
|
|
56
|
+
npm run feltdb:publish -- --yes
|
|
57
|
+
```
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { db, recordAgentActivity } from '../src/feltdb';
|
|
2
|
+
import { activitySummary, type ActivitySummary } from '../capabilities/activity-summary';
|
|
3
|
+
|
|
4
|
+
export const activityAssistant = db.defineAgent({ name: 'activity-assistant', version: 1, description: 'Explains authorized application activity.', capabilities: [activitySummary.id], goals: ['Summarize recent authorized audit events'], constraints: { maxIterations: 1 } });
|
|
5
|
+
|
|
6
|
+
export async function runActivityAssistant(input: { organization: string; user: string }): Promise<ActivitySummary> {
|
|
7
|
+
const execution = await db.createAgentExecution(activityAssistant, 'Summarize recent activity', []);
|
|
8
|
+
try {
|
|
9
|
+
await db.startAgentExecution(execution);
|
|
10
|
+
const result = await activitySummary.execute() as ActivitySummary;
|
|
11
|
+
await recordAgentActivity({ organization: input.organization, user: input.user, assistant: 'Activity Assistant', subject: execution.executionId });
|
|
12
|
+
await db.completeAgentExecution(execution, execution.executionId);
|
|
13
|
+
return result;
|
|
14
|
+
} catch (error) {
|
|
15
|
+
await db.failAgentExecution(execution, error instanceof Error ? error.message : String(error));
|
|
16
|
+
throw error;
|
|
17
|
+
}
|
|
18
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { db, recordAgentActivity } from '../src/feltdb';
|
|
2
|
+
import { projectSearch } from '../capabilities/project-search';
|
|
3
|
+
import { projectSummary, type ProjectSummary } from '../capabilities/project-summary';
|
|
4
|
+
|
|
5
|
+
export const projectAssistant = db.defineAgent({ name: 'project-assistant', version: 1, description: 'Summarizes authorized project state.', capabilities: [projectSearch.id, projectSummary.id], goals: ['Explain project state without widening the actor authorization boundary'], constraints: { maxIterations: 1 } });
|
|
6
|
+
|
|
7
|
+
export async function runProjectAssistant(input: { projectId: string; organization: string; user: string }): Promise<ProjectSummary> {
|
|
8
|
+
const execution = await db.createAgentExecution(projectAssistant, 'Summarize the selected project', [input.projectId]);
|
|
9
|
+
try {
|
|
10
|
+
await db.startAgentExecution(execution);
|
|
11
|
+
const result = await projectSummary.execute(input.projectId) as ProjectSummary;
|
|
12
|
+
await recordAgentActivity({ organization: input.organization, user: input.user, assistant: 'Project Assistant', subject: input.projectId });
|
|
13
|
+
await db.completeAgentExecution(execution, input.projectId);
|
|
14
|
+
return result;
|
|
15
|
+
} catch (error) {
|
|
16
|
+
await db.failAgentExecution(execution, error instanceof Error ? error.message : String(error));
|
|
17
|
+
throw error;
|
|
18
|
+
}
|
|
19
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import type { Capability } from '@feltdb/core';
|
|
2
|
+
import { auditEvents, type AuditEvent } from '../src/feltdb';
|
|
3
|
+
|
|
4
|
+
export interface ActivitySummary { summary: string; total: number; actions: string[] }
|
|
5
|
+
|
|
6
|
+
export const activitySummary: Capability = {
|
|
7
|
+
id: 'activity-summary',
|
|
8
|
+
name: 'Activity summary',
|
|
9
|
+
async execute(): Promise<ActivitySummary> {
|
|
10
|
+
const events = (await auditEvents.all() as AuditEvent[]).slice(-20);
|
|
11
|
+
const actions = [...new Set(events.map(event => event.action))];
|
|
12
|
+
return { total: events.length, actions, summary: events.length ? `${events.length} recent events across ${actions.length} action types: ${actions.join(', ')}.` : 'No application activity has been recorded yet.' };
|
|
13
|
+
},
|
|
14
|
+
};
|