@feltdb/core 0.7.3 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (147) hide show
  1. package/README.md +25 -0
  2. package/dist/application-development.d.ts +78 -0
  3. package/dist/application-development.d.ts.map +1 -0
  4. package/dist/application-development.js +153 -0
  5. package/dist/application-manifest.d.ts +6 -1
  6. package/dist/application-manifest.d.ts.map +1 -1
  7. package/dist/application-manifest.js +10 -3
  8. package/dist/canonical-application.d.ts +27 -0
  9. package/dist/canonical-application.d.ts.map +1 -0
  10. package/dist/canonical-application.js +46 -0
  11. package/dist/cli/ai-model-config.js +97 -0
  12. package/dist/cli/cli.js +1 -1
  13. package/dist/cli/commands.js +664 -120
  14. package/dist/cli/index.js +1 -1
  15. package/dist/cli/managed-environment.js +6 -0
  16. package/dist/cli/managed-project.js +6 -0
  17. package/dist/cli/proposal-client.js +63 -0
  18. package/dist/cli/publish-engine.js +165 -0
  19. package/dist/cli/source-sync.js +183 -0
  20. package/dist/create/cli.js +11 -2
  21. package/dist/create/create.js +38 -16
  22. package/dist/create/managed-account.js +82 -11
  23. package/dist/create/package-versions.js +1 -1
  24. package/dist/create/server-source/crates/feltdb/src/application.rs +41 -1
  25. package/dist/create/server-source/crates/feltdb/src/authority_failover.rs +84 -6
  26. package/dist/create/server-source/crates/feltdb/src/bin/feltdb_node.rs +126 -38
  27. package/dist/create/server-source/crates/feltdb/src/distributed_transactions.rs +72 -34
  28. package/dist/create/server-source/crates/feltdb/src/lib.rs +20 -2
  29. package/dist/create/server-source/crates/feltdb-server/src/app_state.rs +12 -24
  30. package/dist/create/server-source/crates/feltdb-server/src/application_contract.rs +11 -3
  31. package/dist/create/server-source/crates/feltdb-server/src/authenticated_principal.rs +0 -1
  32. package/dist/create/server-source/crates/feltdb-server/src/certification_harness.rs +23 -22
  33. package/dist/create/server-source/crates/feltdb-server/src/delegation_token.rs +5 -15
  34. package/dist/create/server-source/crates/feltdb-server/src/durable_operations.rs +13 -28
  35. package/dist/create/server-source/crates/feltdb-server/src/identity.rs +111 -3
  36. package/dist/create/server-source/crates/feltdb-server/src/key_management.rs +28 -7
  37. package/dist/create/server-source/crates/feltdb-server/src/lib.rs +5 -5
  38. package/dist/create/server-source/crates/feltdb-server/src/main.rs +1960 -143
  39. package/dist/create/server-source/crates/feltdb-server/src/managed_diagnostics.rs +10 -30
  40. package/dist/create/server-source/crates/feltdb-server/src/membership_policy.rs +12 -4
  41. package/dist/create/server-source/crates/feltdb-server/src/request_telemetry.rs +8 -6
  42. package/dist/create/server-source/crates/feltdb-server/src/snapshot_cursor.rs +10 -14
  43. package/dist/create/server-source/crates/feltdb-server/src/tenancy.rs +195 -13
  44. package/dist/create/server-source/crates/feltdb-server/src/tenant_policies.rs +9 -21
  45. package/dist/create/server-source/crates/feltdb-server/src/transaction_idempotency.rs +5 -3
  46. package/dist/create/server-source/crates/feltdb-server/src/transaction_recovery.rs +9 -8
  47. package/dist/create/server-source/crates/feltdb-server/tests/revision_recovery_integration_test.rs +1 -4
  48. package/dist/create/template/default-project/README.md +57 -0
  49. package/dist/create/template/default-project/agents/activity-assistant.ts +18 -0
  50. package/dist/create/template/default-project/agents/project-assistant.ts +19 -0
  51. package/dist/create/template/default-project/capabilities/activity-summary.ts +14 -0
  52. package/dist/create/template/default-project/capabilities/project-search.ts +16 -0
  53. package/dist/create/template/default-project/capabilities/project-summary.ts +15 -0
  54. package/dist/create/template/default-project/feltdb.flow +174 -0
  55. package/dist/create/template/default-project/src/App.tsx +20 -0
  56. package/dist/create/template/default-project/src/context/AuthContext.tsx +27 -0
  57. package/dist/create/template/default-project/src/feltdb.ts +87 -0
  58. package/dist/create/template/default-project/src/index.tsx +14 -0
  59. package/dist/create/template/default-project/src/pages/Activity.tsx +7 -0
  60. package/dist/create/template/default-project/src/pages/Agents.tsx +15 -0
  61. package/dist/create/template/default-project/src/pages/Dashboard.tsx +17 -0
  62. package/dist/create/template/default-project/src/pages/Invitations.tsx +11 -0
  63. package/dist/create/template/default-project/src/pages/Projects.tsx +11 -0
  64. package/dist/create/template/default-project/src/pages/SignIn.tsx +8 -0
  65. package/dist/create/template/default-project/src/pages/SignUp.tsx +8 -0
  66. package/dist/create/template/default-project/src/styles-application.css +21 -0
  67. package/dist/create/template/default-project/src/styles.css +1 -0
  68. package/dist/create/template/default-project/workflows/agent-assisted-summary.ts +1 -0
  69. package/dist/create/template/default-project/workflows/invitation.ts +30 -0
  70. package/dist/create/template/default-project/workflows/project-created.ts +2 -0
  71. package/dist/db.d.ts +49 -1
  72. package/dist/db.d.ts.map +1 -1
  73. package/dist/db.js +77 -0
  74. package/dist/error-codes.d.ts +20 -1
  75. package/dist/error-codes.d.ts.map +1 -1
  76. package/dist/error-codes.js +25 -0
  77. package/dist/flowspec.d.ts +1 -0
  78. package/dist/flowspec.d.ts.map +1 -1
  79. package/dist/flowspec.js +66 -11
  80. package/dist/http-client.d.ts +135 -14
  81. package/dist/http-client.d.ts.map +1 -1
  82. package/dist/http-client.js +79 -31
  83. package/dist/http-db.d.ts +15 -2
  84. package/dist/http-db.d.ts.map +1 -1
  85. package/dist/http-db.js +113 -5
  86. package/dist/index-core.d.ts +5 -0
  87. package/dist/index-core.d.ts.map +1 -1
  88. package/dist/index-core.js +5 -0
  89. package/dist/module-intent.d.ts +37 -0
  90. package/dist/module-intent.d.ts.map +1 -0
  91. package/dist/module-intent.js +81 -0
  92. package/dist/module.d.ts +76 -0
  93. package/dist/module.d.ts.map +1 -0
  94. package/dist/module.js +61 -0
  95. package/dist/proposal.d.ts +161 -0
  96. package/dist/proposal.d.ts.map +1 -0
  97. package/dist/proposal.js +232 -0
  98. package/dist/studio/app.d.ts +5 -1
  99. package/dist/studio/app.d.ts.map +1 -1
  100. package/dist/studio/components/ApplicationDesigner.d.ts +2 -1
  101. package/dist/studio/components/ApplicationDesigner.d.ts.map +1 -1
  102. package/dist/studio/components/AskFeltDB.d.ts +21 -0
  103. package/dist/studio/components/AskFeltDB.d.ts.map +1 -0
  104. package/dist/studio/components/ContractInspector.d.ts +7 -0
  105. package/dist/studio/components/ContractInspector.d.ts.map +1 -0
  106. package/dist/studio/components/ManagedInstancePanel.d.ts.map +1 -1
  107. package/dist/studio/components/ManagedInstancePanel.js +1 -5
  108. package/dist/studio/components/ModuleExplorer.d.ts +6 -0
  109. package/dist/studio/components/ModuleExplorer.d.ts.map +1 -0
  110. package/dist/studio/components/ProposalBrowser.d.ts +12 -0
  111. package/dist/studio/components/ProposalBrowser.d.ts.map +1 -0
  112. package/dist/studio/components/StateExplorer.d.ts +6 -7
  113. package/dist/studio/components/StateExplorer.d.ts.map +1 -1
  114. package/dist/studio/components/index.d.ts +4 -0
  115. package/dist/studio/components/index.d.ts.map +1 -1
  116. package/dist/studio/components/index.js +4 -4
  117. package/dist/studio/components-CNSNrmA9.js +2787 -0
  118. package/dist/studio/index.js +214 -157
  119. package/dist/studio/studio.css +1 -1
  120. package/dist/studio/utils/index.d.ts +2 -0
  121. package/dist/studio/utils/index.d.ts.map +1 -1
  122. package/dist/studio/utils/index.js +14 -12
  123. package/dist/studio/utils/managed-instance.d.ts +0 -4
  124. package/dist/studio/utils/managed-instance.d.ts.map +1 -1
  125. package/dist/studio/utils/managed-instance.js +0 -4
  126. package/dist/studio/utils/proposal-api.d.ts +53 -0
  127. package/dist/studio/utils/proposal-api.d.ts.map +1 -0
  128. package/dist/studio/utils/proposal-api.js +91 -0
  129. package/dist/studio/utils/service-api.d.ts +81 -0
  130. package/dist/studio/utils/service-api.d.ts.map +1 -0
  131. package/dist/studio/utils/service-api.js +51 -0
  132. package/dist/studio-app/assets/dist-CCOk39Uc.js +1 -0
  133. package/dist/studio-app/assets/{feltdb_wasm-DVKsw75S.js → feltdb_wasm-CSB6KVgw.js} +1 -1
  134. package/dist/studio-app/assets/feltdb_wasm_bg-Dd1fnO9U.wasm +0 -0
  135. package/dist/studio-app/assets/index-Bncji3aN.js +43 -0
  136. package/dist/studio-app/assets/{index-C1GWyazR.css → index-yazaWMUN.css} +1 -1
  137. package/dist/studio-app/assets/lib-B2_7fcn7.js +69 -0
  138. package/dist/studio-app/assets/worker-CARZ5g7K.js +69 -0
  139. package/dist/studio-app/index.html +2 -2
  140. package/dist/wasm/feltdb_wasm_bg.wasm +0 -0
  141. package/dist/workspace/local-development-authority.d.ts +3 -0
  142. package/dist/workspace/local-development-authority.d.ts.map +1 -1
  143. package/dist/workspace/local-development-authority.js +124 -2
  144. package/package.json +1 -1
  145. package/dist/studio/components-Dxuhrv8_.js +0 -1832
  146. package/dist/studio-app/assets/feltdb_wasm_bg-DNyNf0yy.wasm +0 -0
  147. package/dist/studio-app/assets/index-B5tnmvSD.js +0 -28
@@ -122,10 +122,7 @@ pub struct DiagnosticsCollector {
122
122
 
123
123
  impl DiagnosticsCollector {
124
124
  /// Creates a new diagnostics collector.
125
- pub fn new(
126
- server_commit: String,
127
- environment: Environment,
128
- ) -> Self {
125
+ pub fn new(server_commit: String, environment: Environment) -> Self {
129
126
  Self {
130
127
  engine_version: ENGINE_VERSION.to_string(),
131
128
  server_commit,
@@ -137,10 +134,7 @@ impl DiagnosticsCollector {
137
134
  }
138
135
 
139
136
  /// Generates a diagnostics report.
140
- pub fn collect_report(
141
- &self,
142
- recovery_status: RecoveryStatus,
143
- ) -> DiagnosticsReport {
137
+ pub fn collect_report(&self, recovery_status: RecoveryStatus) -> DiagnosticsReport {
144
138
  let uptime = now() - self.start_time;
145
139
  let overall_status = if recovery_status.status.is_healthy() {
146
140
  HealthStatus::Healthy
@@ -289,10 +283,7 @@ mod tests {
289
283
 
290
284
  #[test]
291
285
  fn create_diagnostics_collector() {
292
- let collector = DiagnosticsCollector::new(
293
- "abc123def456".to_string(),
294
- Environment::Prod,
295
- );
286
+ let collector = DiagnosticsCollector::new("abc123def456".to_string(), Environment::Prod);
296
287
 
297
288
  assert_eq!(collector.engine_version, ENGINE_VERSION);
298
289
  assert_eq!(collector.protocol_version, PROTOCOL_VERSION);
@@ -302,10 +293,7 @@ mod tests {
302
293
 
303
294
  #[test]
304
295
  fn collect_report_includes_uptime() {
305
- let collector = DiagnosticsCollector::new(
306
- "abc123def456".to_string(),
307
- Environment::Staging,
308
- );
296
+ let collector = DiagnosticsCollector::new("abc123def456".to_string(), Environment::Staging);
309
297
 
310
298
  let recovery_status = RecoveryStatus::default();
311
299
  let report = collector.collect_report(recovery_status);
@@ -327,7 +315,9 @@ mod tests {
327
315
  assert_eq!(recovery.durable_operations, HealthStatus::Degraded);
328
316
  assert_eq!(recovery.status, HealthStatus::Degraded);
329
317
  assert_eq!(recovery.pending_recovery_count, 5);
330
- assert!(recovery.issues.contains(&"durable operations slow".to_string()));
318
+ assert!(recovery
319
+ .issues
320
+ .contains(&"durable operations slow".to_string()));
331
321
  }
332
322
 
333
323
  #[test]
@@ -341,11 +331,7 @@ mod tests {
341
331
 
342
332
  #[test]
343
333
  fn environment_serialization() {
344
- let envs = vec![
345
- Environment::Dev,
346
- Environment::Staging,
347
- Environment::Prod,
348
- ];
334
+ let envs = vec![Environment::Dev, Environment::Staging, Environment::Prod];
349
335
 
350
336
  for env in envs {
351
337
  let json = serde_json::to_string(&env).expect("serialize failed");
@@ -357,10 +343,7 @@ mod tests {
357
343
 
358
344
  #[test]
359
345
  fn diagnostics_report_serialization() {
360
- let collector = DiagnosticsCollector::new(
361
- "abc123def456".to_string(),
362
- Environment::Prod,
363
- );
346
+ let collector = DiagnosticsCollector::new("abc123def456".to_string(), Environment::Prod);
364
347
 
365
348
  let recovery_status = RecoveryStatusBuilder::new()
366
349
  .with_pending_recovery_count(3)
@@ -378,10 +361,7 @@ mod tests {
378
361
 
379
362
  #[test]
380
363
  fn add_subsystem_metrics() {
381
- let collector = DiagnosticsCollector::new(
382
- "abc123def456".to_string(),
383
- Environment::Dev,
384
- );
364
+ let collector = DiagnosticsCollector::new("abc123def456".to_string(), Environment::Dev);
385
365
 
386
366
  let mut report = collector.collect_report(RecoveryStatus::default());
387
367
  let mut details = HashMap::new();
@@ -153,7 +153,9 @@ impl MembershipVerifier {
153
153
  ) -> MembershipDecision {
154
154
  let policy = match self.store.get(policy_name) {
155
155
  Some(p) => p,
156
- None => return MembershipDecision::Denied(format!("policy not found: {}", policy_name)),
156
+ None => {
157
+ return MembershipDecision::Denied(format!("policy not found: {}", policy_name))
158
+ }
157
159
  };
158
160
 
159
161
  // Verify actor field
@@ -203,7 +205,10 @@ impl MembershipVerifier {
203
205
  ));
204
206
  }
205
207
  } else {
206
- return MembershipDecision::Denied(format!("required role not found: {}", required));
208
+ return MembershipDecision::Denied(format!(
209
+ "required role not found: {}",
210
+ required
211
+ ));
207
212
  }
208
213
  }
209
214
 
@@ -221,7 +226,9 @@ impl MembershipVerifier {
221
226
  // Find matching membership record
222
227
  let policy = match self.store.get(policy_name) {
223
228
  Some(p) => p,
224
- None => return MembershipDecision::Denied(format!("policy not found: {}", policy_name)),
229
+ None => {
230
+ return MembershipDecision::Denied(format!("policy not found: {}", policy_name))
231
+ }
225
232
  };
226
233
 
227
234
  for record in records {
@@ -260,7 +267,8 @@ impl MembershipVerifier {
260
267
  let record_actor = self.extract_string(&policy.actor_field, record);
261
268
  let record_tenant = self.extract_string(&policy.tenant_field, record);
262
269
 
263
- if record_actor.as_deref() == Some(actor_id) && record_tenant.as_deref() == Some(tenant_id)
270
+ if record_actor.as_deref() == Some(actor_id)
271
+ && record_tenant.as_deref() == Some(tenant_id)
264
272
  {
265
273
  // Check status
266
274
  if let Some(status_field) = &policy.status_field {
@@ -1,6 +1,6 @@
1
+ use serde::{Deserialize, Serialize};
1
2
  use std::sync::{Arc, Mutex};
2
3
  use std::time::{Duration, Instant, SystemTime};
3
- use serde::{Deserialize, Serialize};
4
4
  use uuid::Uuid;
5
5
 
6
6
  /// Request lifecycle telemetry: captures all timing and context for every /v1/ request.
@@ -101,10 +101,7 @@ impl RequestTelemetryStore {
101
101
  }
102
102
 
103
103
  pub fn all(&self) -> Vec<RequestTelemetry> {
104
- self.records
105
- .lock()
106
- .map(|r| r.clone())
107
- .unwrap_or_default()
104
+ self.records.lock().map(|r| r.clone()).unwrap_or_default()
108
105
  }
109
106
 
110
107
  pub fn recent(&self, count: usize) -> Vec<RequestTelemetry> {
@@ -120,7 +117,12 @@ impl RequestTelemetryStore {
120
117
  pub fn by_transaction_id(&self, tx_id: &str) -> Vec<RequestTelemetry> {
121
118
  self.records
122
119
  .lock()
123
- .map(|r| r.iter().filter(|t| t.transaction_id.as_deref() == Some(tx_id)).cloned().collect())
120
+ .map(|r| {
121
+ r.iter()
122
+ .filter(|t| t.transaction_id.as_deref() == Some(tx_id))
123
+ .cloned()
124
+ .collect()
125
+ })
124
126
  .unwrap_or_default()
125
127
  }
126
128
 
@@ -62,8 +62,7 @@ impl CursorCodec {
62
62
  let bytes = base64::engine::general_purpose::STANDARD
63
63
  .decode(encoded)
64
64
  .map_err(|e| format!("invalid cursor encoding: {e}"))?;
65
- let json = String::from_utf8(bytes)
66
- .map_err(|e| format!("cursor not valid UTF-8: {e}"))?;
65
+ let json = String::from_utf8(bytes).map_err(|e| format!("cursor not valid UTF-8: {e}"))?;
67
66
  serde_json::from_str(&json).map_err(|e| format!("invalid cursor JSON: {e}"))
68
67
  }
69
68
  }
@@ -100,10 +99,7 @@ impl SnapshotCursorManager {
100
99
  };
101
100
 
102
101
  let checksum = self.compute_checksum(&cursor)?;
103
- let cursor_with_checksum = SnapshotCursor {
104
- checksum,
105
- ..cursor
106
- };
102
+ let cursor_with_checksum = SnapshotCursor { checksum, ..cursor };
107
103
 
108
104
  CursorCodec::encode(&cursor_with_checksum)
109
105
  }
@@ -144,9 +140,7 @@ impl SnapshotCursorManager {
144
140
 
145
141
  // Verify authorization context hasn't changed
146
142
  if cursor.auth_context_hash != current_auth_context_hash {
147
- return Err(
148
- "authorization context changed since cursor creation".into(),
149
- );
143
+ return Err("authorization context changed since cursor creation".into());
150
144
  }
151
145
 
152
146
  // Verify checksum
@@ -166,7 +160,11 @@ impl SnapshotCursorManager {
166
160
  }
167
161
 
168
162
  /// Computes hash of authorization context.
169
- pub fn hash_auth_context(actor_id: &str, tenant_id: &str, required_role: Option<&str>) -> String {
163
+ pub fn hash_auth_context(
164
+ actor_id: &str,
165
+ tenant_id: &str,
166
+ required_role: Option<&str>,
167
+ ) -> String {
170
168
  let mut hasher = Sha256::new();
171
169
  hasher.update(actor_id.as_bytes());
172
170
  hasher.update(tenant_id.as_bytes());
@@ -289,10 +287,8 @@ mod tests {
289
287
  #[test]
290
288
  fn reject_cursor_when_auth_context_changes() {
291
289
  let manager = SnapshotCursorManager::new("release_jobs".into());
292
- let auth_hash1 =
293
- SnapshotCursorManager::hash_auth_context("user_123", "tenant_prod", None);
294
- let auth_hash2 =
295
- SnapshotCursorManager::hash_auth_context("user_123", "tenant_other", None);
290
+ let auth_hash1 = SnapshotCursorManager::hash_auth_context("user_123", "tenant_prod", None);
291
+ let auth_hash2 = SnapshotCursorManager::hash_auth_context("user_123", "tenant_other", None);
296
292
 
297
293
  let encoded = manager
298
294
  .create_cursor("snap_123".into(), 5, 0, auth_hash1)
@@ -67,6 +67,16 @@ pub struct PlatformUser {
67
67
  pub status: MembershipStatus,
68
68
  pub created_at: u64,
69
69
  }
70
+ #[derive(Debug, Clone, Serialize, Deserialize)]
71
+ pub struct ApplicationServicePrincipal {
72
+ pub service_id: String,
73
+ pub tenant_id: String,
74
+ pub application_id: String,
75
+ pub capabilities: Vec<String>,
76
+ pub status: MembershipStatus,
77
+ pub created_by: String,
78
+ pub created_at: u64,
79
+ }
70
80
  #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
71
81
  #[serde(rename_all = "snake_case")]
72
82
  pub enum MembershipStatus {
@@ -104,6 +114,8 @@ struct PlatformRecords {
104
114
  applications: Vec<Application>,
105
115
  tenant_memberships: Vec<TenantMembership>,
106
116
  application_memberships: Vec<ApplicationMembership>,
117
+ #[serde(default)]
118
+ application_service_principals: Vec<ApplicationServicePrincipal>,
107
119
  invitations: Vec<Invitation>,
108
120
  audit: Vec<PlatformAuditEvent>,
109
121
  }
@@ -151,7 +163,16 @@ fn platform_owner_user(user_id: &str) -> bool {
151
163
  .split(',')
152
164
  .map(|owner| owner.trim().to_lowercase())
153
165
  .filter(|owner| !owner.is_empty())
154
- .map(|owner| format!("user_{}", URL_SAFE_NO_PAD.encode(owner).chars().take(18).collect::<String>()))
166
+ .map(|owner| {
167
+ format!(
168
+ "user_{}",
169
+ URL_SAFE_NO_PAD
170
+ .encode(owner)
171
+ .chars()
172
+ .take(18)
173
+ .collect::<String>()
174
+ )
175
+ })
155
176
  .any(|owner_id| owner_id == user_id)
156
177
  }
157
178
 
@@ -241,6 +262,19 @@ pub fn application_role_grants() -> Vec<RoleGrant> {
241
262
  "deployment:execute".into(),
242
263
  ],
243
264
  },
265
+ RoleGrant {
266
+ role: "actor".into(),
267
+ capabilities: vec![
268
+ "application:read".into(),
269
+ "application:revision:read".into(),
270
+ "state:read".into(),
271
+ "state:write".into(),
272
+ "events:read".into(),
273
+ "workflows:run".into(),
274
+ "agents:run".into(),
275
+ "capabilities:run".into(),
276
+ ],
277
+ },
244
278
  RoleGrant {
245
279
  role: "viewer".into(),
246
280
  capabilities: vec![
@@ -271,6 +305,90 @@ impl TenancyStore {
271
305
  records: Arc::new(RwLock::new(records)),
272
306
  })
273
307
  }
308
+ pub fn register_application_actor(
309
+ &self,
310
+ user_id: &str,
311
+ application_id: &str,
312
+ ) -> Result<(), String> {
313
+ let mut records = self
314
+ .records
315
+ .write()
316
+ .map_err(|_| "tenancy store lock poisoned")?;
317
+ if !records
318
+ .applications
319
+ .iter()
320
+ .any(|application| application.id == application_id && application.status == "active")
321
+ {
322
+ return Err("application not found".into());
323
+ }
324
+ if !records.application_memberships.iter().any(|membership| {
325
+ membership.application_id == application_id
326
+ && membership.user_id == user_id
327
+ && membership.status == MembershipStatus::Active
328
+ }) {
329
+ let timestamp = now();
330
+ records.application_memberships.push(ApplicationMembership {
331
+ application_id: application_id.into(),
332
+ user_id: user_id.into(),
333
+ role: "actor".into(),
334
+ status: MembershipStatus::Active,
335
+ created_at: timestamp,
336
+ updated_at: timestamp,
337
+ });
338
+ self.persist(&records)?;
339
+ }
340
+ Ok(())
341
+ }
342
+ pub fn register_application_service(
343
+ &self,
344
+ actor: &str,
345
+ service_id: &str,
346
+ application_id: &str,
347
+ capabilities: Vec<String>,
348
+ ) -> Result<(), String> {
349
+ let tenant_id = self
350
+ .application_tenant(application_id)
351
+ .ok_or_else(|| "application not found".to_string())?;
352
+ if !self.authorize_application(actor, &tenant_id, application_id, "members:write") {
353
+ return Err("forbidden".into());
354
+ }
355
+ if capabilities.is_empty() {
356
+ return Err("service capabilities are required".into());
357
+ }
358
+ let mut records = self
359
+ .records
360
+ .write()
361
+ .map_err(|_| "tenancy store lock poisoned")?;
362
+ if records
363
+ .application_service_principals
364
+ .iter()
365
+ .any(|principal| {
366
+ principal.service_id == service_id && principal.status == MembershipStatus::Active
367
+ })
368
+ {
369
+ return Err("service principal already exists".into());
370
+ }
371
+ records
372
+ .application_service_principals
373
+ .push(ApplicationServicePrincipal {
374
+ service_id: service_id.into(),
375
+ tenant_id: tenant_id.clone(),
376
+ application_id: application_id.into(),
377
+ capabilities,
378
+ status: MembershipStatus::Active,
379
+ created_by: actor.into(),
380
+ created_at: now(),
381
+ });
382
+ Self::audit(
383
+ &mut records,
384
+ "application.service.created",
385
+ actor,
386
+ &tenant_id,
387
+ Some(application_id),
388
+ service_id,
389
+ );
390
+ self.persist(&records)
391
+ }
274
392
  fn persist(&self, records: &PlatformRecords) -> Result<(), String> {
275
393
  if let Some(parent) = self.path.parent() {
276
394
  fs::create_dir_all(parent).map_err(|error| error.to_string())?;
@@ -325,13 +443,16 @@ impl TenancyStore {
325
443
  }
326
444
  pub fn platform_inventory(
327
445
  &self,
328
- ) -> Result<(
329
- Vec<PlatformUser>,
330
- Vec<Tenant>,
331
- Vec<Application>,
332
- Vec<TenantMembership>,
333
- Vec<ApplicationMembership>,
334
- ), String> {
446
+ ) -> Result<
447
+ (
448
+ Vec<PlatformUser>,
449
+ Vec<Tenant>,
450
+ Vec<Application>,
451
+ Vec<TenantMembership>,
452
+ Vec<ApplicationMembership>,
453
+ ),
454
+ String,
455
+ > {
335
456
  let records = self
336
457
  .records
337
458
  .read()
@@ -382,7 +503,14 @@ impl TenancyStore {
382
503
  .ok_or("platform user not found")?;
383
504
  user.status = status;
384
505
  let updated = user.clone();
385
- Self::audit(&mut records, "platform.user.updated", actor, "platform", None, user_id);
506
+ Self::audit(
507
+ &mut records,
508
+ "platform.user.updated",
509
+ actor,
510
+ "platform",
511
+ None,
512
+ user_id,
513
+ );
386
514
  self.persist(&records)?;
387
515
  Ok(updated)
388
516
  }
@@ -616,13 +744,18 @@ impl TenancyStore {
616
744
  records
617
745
  .application_memberships
618
746
  .retain(|membership| !application_ids.contains(&membership.application_id));
747
+ records
748
+ .application_service_principals
749
+ .retain(|principal| !application_ids.contains(&principal.application_id));
619
750
  records
620
751
  .tenant_memberships
621
752
  .retain(|membership| membership.tenant_id != tenant_id);
622
753
  records
623
754
  .invitations
624
755
  .retain(|invitation| invitation.tenant_id != tenant_id);
625
- records.tenants.retain(|candidate| candidate.id != tenant_id);
756
+ records
757
+ .tenants
758
+ .retain(|candidate| candidate.id != tenant_id);
626
759
  self.persist(&records)?;
627
760
  Ok(tenant_id)
628
761
  }
@@ -708,6 +841,9 @@ impl TenancyStore {
708
841
  records
709
842
  .application_memberships
710
843
  .retain(|membership| membership.application_id != application_id);
844
+ records
845
+ .application_service_principals
846
+ .retain(|principal| principal.application_id != application_id);
711
847
  self.persist(&records)
712
848
  }
713
849
 
@@ -852,9 +988,6 @@ impl TenancyStore {
852
988
  application_id: &str,
853
989
  capability: &str,
854
990
  ) -> bool {
855
- if !self.platform_user_active(user_id) {
856
- return false;
857
- }
858
991
  let records = match self.records.read() {
859
992
  Ok(records) => records,
860
993
  Err(_) => return false,
@@ -866,6 +999,30 @@ impl TenancyStore {
866
999
  {
867
1000
  return false;
868
1001
  }
1002
+ if records
1003
+ .application_service_principals
1004
+ .iter()
1005
+ .any(|principal| {
1006
+ principal.service_id == user_id
1007
+ && principal.tenant_id == tenant_id
1008
+ && principal.application_id == application_id
1009
+ && principal.status == MembershipStatus::Active
1010
+ && principal
1011
+ .capabilities
1012
+ .iter()
1013
+ .any(|allowed| allowed == "*" || allowed == capability)
1014
+ })
1015
+ {
1016
+ return true;
1017
+ }
1018
+ if records
1019
+ .users
1020
+ .iter()
1021
+ .find(|user| user.user_id == user_id)
1022
+ .is_some_and(|user| user.status != MembershipStatus::Active)
1023
+ {
1024
+ return false;
1025
+ }
869
1026
  if platform_owner_user(user_id) {
870
1027
  return true;
871
1028
  }
@@ -1143,4 +1300,29 @@ mod tests {
1143
1300
  "application:revision:promote"
1144
1301
  ));
1145
1302
  }
1303
+
1304
+ #[test]
1305
+ fn application_service_is_bound_to_one_application_and_explicit_capabilities() {
1306
+ let store = store("application-service");
1307
+ let tenant = store.create_tenant("owner", "Acme").unwrap();
1308
+ let app = store
1309
+ .create_application("owner", &tenant.id, "Runtime")
1310
+ .unwrap();
1311
+ let other = store
1312
+ .create_application("owner", &tenant.id, "Other")
1313
+ .unwrap();
1314
+
1315
+ store
1316
+ .register_application_service(
1317
+ "owner",
1318
+ "key_runtime",
1319
+ &app.id,
1320
+ vec!["application:read".into(), "state:read".into()],
1321
+ )
1322
+ .unwrap();
1323
+
1324
+ assert!(store.authorize_application("key_runtime", &tenant.id, &app.id, "state:read"));
1325
+ assert!(!store.authorize_application("key_runtime", &tenant.id, &app.id, "state:write"));
1326
+ assert!(!store.authorize_application("key_runtime", &tenant.id, &other.id, "state:read"));
1327
+ }
1146
1328
  }
@@ -159,10 +159,7 @@ impl PolicyEvaluator {
159
159
  };
160
160
 
161
161
  if !policy.operations.read {
162
- return PolicyDecision::Denied(format!(
163
- "read operation not allowed on {}",
164
- resource
165
- ));
162
+ return PolicyDecision::Denied(format!("read operation not allowed on {}", resource));
166
163
  }
167
164
 
168
165
  self.check_tenant_access(context, &policy, record)
@@ -181,10 +178,7 @@ impl PolicyEvaluator {
181
178
  };
182
179
 
183
180
  if !policy.operations.write {
184
- return PolicyDecision::Denied(format!(
185
- "write operation not allowed on {}",
186
- resource
187
- ));
181
+ return PolicyDecision::Denied(format!("write operation not allowed on {}", resource));
188
182
  }
189
183
 
190
184
  // Check that new record has correct tenant_id
@@ -206,20 +200,19 @@ impl PolicyEvaluator {
206
200
  };
207
201
 
208
202
  if !policy.operations.write {
209
- return PolicyDecision::Denied(format!(
210
- "write operation not allowed on {}",
211
- resource
212
- ));
203
+ return PolicyDecision::Denied(format!("write operation not allowed on {}", resource));
213
204
  }
214
205
 
215
206
  // Must authorize existing record
216
- if let PolicyDecision::Denied(msg) = self.check_tenant_access(context, &policy, existing_record)
207
+ if let PolicyDecision::Denied(msg) =
208
+ self.check_tenant_access(context, &policy, existing_record)
217
209
  {
218
210
  return PolicyDecision::Denied(format!("cannot update: {}", msg));
219
211
  }
220
212
 
221
213
  // Must authorize new record state
222
- if let PolicyDecision::Denied(msg) = self.check_tenant_access(context, &policy, new_record) {
214
+ if let PolicyDecision::Denied(msg) = self.check_tenant_access(context, &policy, new_record)
215
+ {
223
216
  return PolicyDecision::Denied(format!("update would result in: {}", msg));
224
217
  }
225
218
 
@@ -257,10 +250,7 @@ impl PolicyEvaluator {
257
250
  };
258
251
 
259
252
  if !policy.operations.delete {
260
- return PolicyDecision::Denied(format!(
261
- "delete operation not allowed on {}",
262
- resource
263
- ));
253
+ return PolicyDecision::Denied(format!("delete operation not allowed on {}", resource));
264
254
  }
265
255
 
266
256
  self.check_tenant_access(context, &policy, record)
@@ -297,9 +287,7 @@ impl PolicyEvaluator {
297
287
  let record_tenant = self.extract_tenant_id(policy, record);
298
288
 
299
289
  if record_tenant != Some(context.tenant_id.clone()) {
300
- return PolicyDecision::Denied(format!(
301
- "record belongs to different tenant"
302
- ));
290
+ return PolicyDecision::Denied(format!("record belongs to different tenant"));
303
291
  }
304
292
 
305
293
  // Check owner field if policy specifies one
@@ -1,7 +1,7 @@
1
- use std::collections::HashMap;
2
- use std::sync::{Arc, Mutex};
3
1
  use serde::{Deserialize, Serialize};
4
2
  use sha2::{Digest, Sha256};
3
+ use std::collections::HashMap;
4
+ use std::sync::{Arc, Mutex};
5
5
 
6
6
  /// Durable transaction idempotency: prevents re-execution of already-committed transactions.
7
7
  /// Every committed transaction is stored with its result. If the client retries with the
@@ -148,7 +148,9 @@ impl TransactionIdempotencyStore {
148
148
  let total_entries = store.values().map(|v| v.len()).sum();
149
149
  let replay_protected = store
150
150
  .values()
151
- .filter(|entries| !entries.is_empty() && entries[0].status == TransactionStatus::Committed)
151
+ .filter(|entries| {
152
+ !entries.is_empty() && entries[0].status == TransactionStatus::Committed
153
+ })
152
154
  .count();
153
155
 
154
156
  IdempotencyStatistics {
@@ -227,7 +227,8 @@ impl TransactionRecoveryStore {
227
227
  Some(r) => {
228
228
  // Same payload = safe to retry
229
229
  if r.payload_hash == payload_hash {
230
- Ok(r.status == TransactionStatus::Committed || r.status == TransactionStatus::Unknown)
230
+ Ok(r.status == TransactionStatus::Committed
231
+ || r.status == TransactionStatus::Unknown)
231
232
  } else {
232
233
  // Different payload = conflict
233
234
  Ok(false)
@@ -323,7 +324,9 @@ mod tests {
323
324
  .record_committed("txn_123".into(), hash1, 42)
324
325
  .expect("record failed");
325
326
 
326
- let is_safe = store.is_retry_safe("txn_123", &hash2).expect("check failed");
327
+ let is_safe = store
328
+ .is_retry_safe("txn_123", &hash2)
329
+ .expect("check failed");
327
330
  assert!(!is_safe);
328
331
  }
329
332
 
@@ -332,7 +335,9 @@ mod tests {
332
335
  let store = TransactionRecoveryStore::new();
333
336
  let hash = test_payload_hash();
334
337
 
335
- let is_safe = store.is_retry_safe("txn_unknown", &hash).expect("check failed");
338
+ let is_safe = store
339
+ .is_retry_safe("txn_unknown", &hash)
340
+ .expect("check failed");
336
341
  assert!(is_safe);
337
342
  }
338
343
 
@@ -381,11 +386,7 @@ mod tests {
381
386
  let hash = test_payload_hash();
382
387
 
383
388
  store
384
- .record_rejected(
385
- "txn_123".into(),
386
- hash,
387
- "required field missing".into(),
388
- )
389
+ .record_rejected("txn_123".into(), hash, "required field missing".into())
389
390
  .expect("record failed");
390
391
 
391
392
  let status = store.get_status("txn_123").expect("get failed");
@@ -124,10 +124,7 @@ mod revision_recovery_tests {
124
124
  });
125
125
 
126
126
  // Both requests should have the same recovery_id
127
- assert_eq!(
128
- request1.get("recoveryId"),
129
- request2.get("recoveryId")
130
- );
127
+ assert_eq!(request1.get("recoveryId"), request2.get("recoveryId"));
131
128
  }
132
129
 
133
130
  /// Test environment parameter handling