create-feltdb 0.5.2 → 0.5.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.
@@ -15,18 +15,22 @@ export async function configureManagedAccount(options) {
15
15
  body: JSON.stringify({ email: options.email, password: options.password }),
16
16
  });
17
17
  await responseJson(authenticated, 'Could not create or sign in to the managed account');
18
+ // fetch follows the feltdb.com -> www.feltdb.com redirect. Reuse the final
19
+ // origin so Node does not strip the explicit Cookie header on later
20
+ // cross-origin redirects.
21
+ const sessionOrigin = authenticated.url ? new URL(authenticated.url).origin : accountUrl;
18
22
  const cookie = authenticated.headers.get('set-cookie')?.split(';', 1)[0];
19
23
  if (!cookie)
20
24
  throw new Error('Managed account service did not return a secure session');
21
25
  const headers = { Cookie: cookie, 'Content-Type': 'application/json' };
22
- const tenants = await responseJson(await request(`${accountUrl}/api/tenants`, { headers }), 'Could not load managed workspaces');
26
+ const tenants = await responseJson(await request(`${sessionOrigin}/api/tenants`, { headers }), 'Could not load managed workspaces');
23
27
  let tenant = Array.isArray(tenants) ? tenants[0] : undefined;
24
28
  if (!tenant) {
25
- tenant = await responseJson(await request(`${accountUrl}/api/tenants`, {
29
+ tenant = await responseJson(await request(`${sessionOrigin}/api/tenants`, {
26
30
  method: 'POST', headers, body: JSON.stringify({ name: `${options.applicationName} Workspace` }),
27
31
  }), 'Could not create the managed workspace');
28
32
  }
29
- const applicationsUrl = `${accountUrl}/api/tenants/${encodeURIComponent(tenant.id)}/applications`;
33
+ const applicationsUrl = `${sessionOrigin}/api/tenants/${encodeURIComponent(tenant.id)}/applications`;
30
34
  const applications = await responseJson(await request(applicationsUrl, { headers }), 'Could not load managed applications');
31
35
  let application = Array.isArray(applications)
32
36
  ? applications.find(value => value.name === options.applicationName)
@@ -36,7 +40,10 @@ export async function configureManagedAccount(options) {
36
40
  method: 'POST', headers, body: JSON.stringify({ name: options.applicationName }),
37
41
  }), 'Could not create the managed application');
38
42
  }
39
- const key = await responseJson(await request(`${apiUrl}/api/keys`, {
43
+ // Provision through the account origin that issued the human session. The
44
+ // resulting key is still used against apiUrl, but setup must not require a
45
+ // browser session to be trusted by every managed data-plane hostname.
46
+ const key = await responseJson(await request(`${sessionOrigin}/api/keys`, {
40
47
  method: 'POST', headers,
41
48
  body: JSON.stringify({
42
49
  id: `${options.applicationName}-cli`,
@@ -1,4 +1,4 @@
1
1
  // One release train keeps generated applications installable. The repository
2
2
  // validation script checks these values against every workspace manifest.
3
- export const FELTDB_PACKAGE_VERSION = '0.5.2';
3
+ export const FELTDB_PACKAGE_VERSION = '0.5.4';
4
4
  export const feltdbPackageRange = `^${FELTDB_PACKAGE_VERSION}`;
@@ -308,6 +308,7 @@ struct Inner {
308
308
  capability_locations: CapabilityLocationRegistry,
309
309
  bootstrapped: bool,
310
310
  applied_transactions: HashSet<String>,
311
+ transaction_payload_hashes: HashMap<String, String>,
311
312
  collection_cardinality: HashMap<String, u64>,
312
313
  }
313
314
 
@@ -361,6 +362,8 @@ pub enum JsonCasResult {
361
362
  struct TransactionLogRecord {
362
363
  record_type: String,
363
364
  transaction_id: String,
365
+ #[serde(default)]
366
+ payload_hash: Option<String>,
364
367
  state_before: u64,
365
368
  state_after: u64,
366
369
  rows: Vec<StoredRow>,
@@ -894,6 +897,10 @@ impl FeltDb {
894
897
  .contains(transaction_id))
895
898
  }
896
899
 
900
+ pub fn applied_transaction_payload_hash(&self, transaction_id: &str) -> Result<Option<String>> {
901
+ Ok(self.inner.lock().expect("lock poisoned").transaction_payload_hashes.get(transaction_id).cloned())
902
+ }
903
+
897
904
  /// Atomically validates and durably applies an ordered mutation batch.
898
905
  /// The complete transaction is represented by one log record and becomes
899
906
  /// visible in memory only after that record has been appended successfully.
@@ -904,10 +911,29 @@ impl FeltDb {
904
911
  preconditions: &[AtomicPrecondition],
905
912
  mutations: &[AtomicMutation],
906
913
  audit: Option<Value>,
914
+ ) -> Result<AtomicCommit> {
915
+ self.apply_atomic_transaction_content_addressed(transaction_id, None, expected_parent, preconditions, mutations, audit)
916
+ }
917
+
918
+ /// Applies a transaction whose identifier is durably bound to a canonical
919
+ /// payload hash. The same ID with a different payload is always a conflict.
920
+ pub fn apply_atomic_transaction_content_addressed(
921
+ &self,
922
+ transaction_id: &str,
923
+ payload_hash: Option<&str>,
924
+ expected_parent: Option<u64>,
925
+ preconditions: &[AtomicPrecondition],
926
+ mutations: &[AtomicMutation],
927
+ audit: Option<Value>,
907
928
  ) -> Result<AtomicCommit> {
908
929
  let (commit, events) = {
909
930
  let mut inner = self.inner.lock().expect("lock poisoned");
910
931
  if inner.applied_transactions.contains(transaction_id) {
932
+ if let Some(expected_hash) = payload_hash {
933
+ if inner.transaction_payload_hashes.get(transaction_id).map(String::as_str) != Some(expected_hash) {
934
+ return Err(FlowError::CapabilityError(format!("TRANSACTION_ID_PAYLOAD_MISMATCH:{transaction_id}")));
935
+ }
936
+ }
911
937
  return Ok(AtomicCommit {
912
938
  transaction_id: transaction_id.into(),
913
939
  state_before: inner.sequence,
@@ -1002,6 +1028,7 @@ impl FeltDb {
1002
1028
  let record = TransactionLogRecord {
1003
1029
  record_type: "feltdb.transaction.v1".into(),
1004
1030
  transaction_id: transaction_id.into(),
1031
+ payload_hash: payload_hash.map(str::to_owned),
1005
1032
  state_before,
1006
1033
  state_after: next_sequence,
1007
1034
  rows: rows.clone(),
@@ -1042,6 +1069,7 @@ impl FeltDb {
1042
1069
  }
1043
1070
  inner.sync_state.merge_vector_clock(&vector_clock);
1044
1071
  inner.applied_transactions.insert(transaction_id.into());
1072
+ if let Some(hash) = payload_hash { inner.transaction_payload_hashes.insert(transaction_id.into(), hash.into()); }
1045
1073
  let events = rows
1046
1074
  .iter()
1047
1075
  .map(|row| ChangeEvent {
@@ -2137,6 +2165,9 @@ fn replay_log(inner: &mut Inner) -> Result<()> {
2137
2165
  }
2138
2166
  if value.get("record_type").and_then(Value::as_str) == Some("feltdb.transaction.v1") {
2139
2167
  let transaction: TransactionLogRecord = serde_json::from_value(value)?;
2168
+ if let Some(hash) = transaction.payload_hash.clone() {
2169
+ inner.transaction_payload_hashes.insert(transaction.transaction_id.clone(), hash);
2170
+ }
2140
2171
  if inner
2141
2172
  .applied_transactions
2142
2173
  .insert(transaction.transaction_id)
@@ -1913,6 +1913,23 @@ pub fn execute_transaction(
1913
1913
  schema: &StateSchema,
1914
1914
  request: &TransactionRequest,
1915
1915
  write_policy: Option<PolicySubject>,
1916
+ ) -> Result<TransactionResult, StateFailure> {
1917
+ execute_transaction_with_policies(db, schema, request, write_policy.as_ref(), None)
1918
+ }
1919
+ pub fn execute_transaction_with_collection_policies(
1920
+ db: &FeltDb,
1921
+ schema: &StateSchema,
1922
+ request: &TransactionRequest,
1923
+ write_policies: &BTreeMap<String, PolicySubject>,
1924
+ ) -> Result<TransactionResult, StateFailure> {
1925
+ execute_transaction_with_policies(db, schema, request, None, Some(write_policies))
1926
+ }
1927
+ fn execute_transaction_with_policies(
1928
+ db: &FeltDb,
1929
+ schema: &StateSchema,
1930
+ request: &TransactionRequest,
1931
+ uniform_write_policy: Option<&PolicySubject>,
1932
+ collection_write_policies: Option<&BTreeMap<String, PolicySubject>>,
1916
1933
  ) -> Result<TransactionResult, StateFailure> {
1917
1934
  if request.application_id != schema.application_id
1918
1935
  || request.revision_id != schema.revision_id
@@ -1944,19 +1961,25 @@ pub fn execute_transaction(
1944
1961
  request.application_id.as_str(),
1945
1962
  request.revision_id.as_str(),
1946
1963
  request.schema_version,
1964
+ request.state_namespace.as_deref(),
1947
1965
  request.causal_parent,
1948
1966
  &request.authorization.subject,
1967
+ &request.preconditions,
1949
1968
  &request.operations,
1950
1969
  ))
1951
1970
  .map_err(StateFailure::storage)?;
1952
1971
  let transaction_id = request
1953
1972
  .transaction_id
1954
1973
  .clone()
1955
- .unwrap_or_else(|| format!("tx_{:x}", Sha256::digest(canonical)));
1974
+ .unwrap_or_else(|| format!("tx_{:x}", Sha256::digest(&canonical)));
1975
+ let payload_hash = format!("sha256:{:x}", Sha256::digest(&canonical));
1956
1976
  if db
1957
1977
  .has_applied_transaction(&transaction_id)
1958
1978
  .map_err(StateFailure::storage)?
1959
1979
  {
1980
+ if db.applied_transaction_payload_hash(&transaction_id).map_err(StateFailure::storage)?.as_deref() != Some(payload_hash.as_str()) {
1981
+ return Err(StateFailure::new("CONFLICT", "transaction ID was already committed with a different canonical payload"));
1982
+ }
1960
1983
  let state = db.sequence().map_err(StateFailure::storage)?;
1961
1984
  let cursor = db
1962
1985
  .operation_versions()
@@ -2088,7 +2111,8 @@ pub fn execute_transaction(
2088
2111
  .unwrap_or_else(|| persisted.map(|row| Some(row.value.clone())).unwrap_or(None));
2089
2112
 
2090
2113
  // Per-operation record authorization
2091
- if let Some(policy) = &write_policy {
2114
+ let write_policy = collection_write_policies.and_then(|policies| policies.get(&operation.collection)).or(uniform_write_policy);
2115
+ if let Some(policy) = write_policy {
2092
2116
  let actor = if request.authorization.subject.is_empty()
2093
2117
  || request.authorization.subject == ":"
2094
2118
  {
@@ -2279,8 +2303,9 @@ pub fn execute_transaction(
2279
2303
  }
2280
2304
  }
2281
2305
  let commit = db
2282
- .apply_atomic_transaction(
2306
+ .apply_atomic_transaction_content_addressed(
2283
2307
  &transaction_id,
2308
+ Some(&payload_hash),
2284
2309
  request.causal_parent,
2285
2310
  &preconditions,
2286
2311
  &mutations,
@@ -2760,6 +2785,17 @@ mod tests {
2760
2785
  assert_eq!(failure.actual, Some(Value::from(1)));
2761
2786
  }
2762
2787
  #[test]
2788
+ fn explicit_transaction_id_is_bound_to_canonical_payload_across_restart() {
2789
+ let path=std::env::temp_dir().join(format!("feltdb-content-id-{}-{}.log",std::process::id(),crate::now_ms()));
2790
+ let schema=schema();
2791
+ let request=TransactionRequest{transaction_id:Some("stable-client-id".into()),tenant_id:"tenant".into(),application_id:"app".into(),revision_id:"rev".into(),schema_version:1,state_namespace:None,causal_parent:None,authorization:auth(),preconditions:vec![],operations:vec![TransactionOperation{kind:TransactionOperationKind::Insert,collection:"incidents".into(),id:"content-bound".into(),value:serde_json::json!({"title":"Original"}),if_version:None}]};
2792
+ {let db=crate::open(&path).unwrap();assert!(!execute_transaction(&db,&schema,&request,None).unwrap().duplicate);assert!(execute_transaction(&db,&schema,&request,None).unwrap().duplicate);}
2793
+ let reopened=crate::open(&path).unwrap();assert!(execute_transaction(&reopened,&schema,&request,None).unwrap().duplicate);
2794
+ let mut changed=request.clone();changed.operations[0].value=serde_json::json!({"title":"Changed"});
2795
+ let failure=execute_transaction(&reopened,&schema,&changed,None).unwrap_err();assert_eq!(failure.code,"CONFLICT");assert!(failure.message.contains("different canonical payload"));
2796
+ std::fs::remove_file(path).ok();
2797
+ }
2798
+ #[test]
2763
2799
  fn read_context_remains_a_stable_snapshot() {
2764
2800
  let db = db("snapshot-read");
2765
2801
  let schema = schema();
@@ -50,6 +50,12 @@ pub struct ApiKeySummary {
50
50
  pub revoked: bool,
51
51
  }
52
52
 
53
+ #[derive(Debug, Clone)]
54
+ pub struct CreatedApiKey {
55
+ pub id: String,
56
+ pub secret: String,
57
+ }
58
+
53
59
  #[derive(Debug, Clone)]
54
60
  pub struct Principal {
55
61
  pub key_id: String,
@@ -152,7 +158,7 @@ impl KeyStore {
152
158
  name: String,
153
159
  scopes: Vec<String>,
154
160
  namespaces: Vec<String>,
155
- ) -> Result<String, String> {
161
+ ) -> Result<CreatedApiKey, String> {
156
162
  let secret: String = rand::thread_rng()
157
163
  .sample_iter(&Alphanumeric)
158
164
  .take(48)
@@ -173,7 +179,7 @@ impl KeyStore {
173
179
  .write()
174
180
  .map_err(|_| "API key store lock poisoned".to_string())?
175
181
  .push(ApiKeyRecord {
176
- id,
182
+ id: id.clone(),
177
183
  name,
178
184
  hash,
179
185
  scopes,
@@ -181,7 +187,7 @@ impl KeyStore {
181
187
  revoked: false,
182
188
  });
183
189
  self.persist()?;
184
- Ok(token)
190
+ Ok(CreatedApiKey { id, secret: token })
185
191
  }
186
192
 
187
193
  pub fn list(&self) -> Result<Vec<ApiKeySummary>, String> {
@@ -255,13 +261,14 @@ mod tests {
255
261
  .as_nanos()
256
262
  ));
257
263
  let store = KeyStore::load(&path).unwrap();
258
- let token = store
264
+ let created = store
259
265
  .create(
260
266
  "test".to_string(),
261
267
  vec!["state:read".to_string()],
262
268
  vec!["alpha".to_string()],
263
269
  )
264
270
  .unwrap();
271
+ let token = created.secret;
265
272
 
266
273
  let file = fs::read_to_string(&path).unwrap();
267
274
  assert!(!file.contains(&token));
@@ -271,8 +278,7 @@ mod tests {
271
278
  .permits("state:read"));
272
279
  assert!(store.authenticate(&token, "beta").is_none());
273
280
  assert!(store.authenticate("fdb_live_wrong", "alpha").is_none());
274
- let id = store.list().unwrap().pop().unwrap().id;
275
- assert!(store.revoke(&id).unwrap());
281
+ assert!(store.revoke(&created.id).unwrap());
276
282
  assert!(store.authenticate(&token, "alpha").is_none());
277
283
  assert!(!store.revoke("missing").unwrap());
278
284
  let _ = fs::remove_file(path);
@@ -45,11 +45,11 @@ pub async fn create_key(
45
45
  .unwrap_or_default()
46
46
  .as_secs();
47
47
 
48
- let id = req.id.unwrap_or_else(|| format!("key-{}", timestamp));
49
- let secret = state
48
+ let name = req.id.unwrap_or_else(|| format!("key-{}", timestamp));
49
+ let key = state
50
50
  .keys
51
51
  .create(
52
- id.clone(),
52
+ name,
53
53
  req.scopes,
54
54
  vec![req.namespace],
55
55
  )
@@ -57,7 +57,10 @@ pub async fn create_key(
57
57
 
58
58
  Ok((
59
59
  StatusCode::CREATED,
60
- Json(CreateKeyResponse { secret, id }),
60
+ Json(CreateKeyResponse {
61
+ secret: key.secret,
62
+ id: key.id,
63
+ }),
61
64
  ))
62
65
  }
63
66
 
@@ -44,7 +44,7 @@ use feltdb::{
44
44
  policy_evaluation::{Actor, PolicyContext, PolicyEvaluator, PolicySubject},
45
45
  state_contract::{
46
46
  begin_read, compare_schemas, execute_query as execute_state_query, execute_transaction,
47
- schema_from_revision, validate_schema, AuthorizationContext, CanonicalQuery, QueryFilter,
47
+ execute_transaction_with_collection_policies, schema_from_revision, validate_schema, AuthorizationContext, CanonicalQuery, QueryFilter,
48
48
  StateSchema, TransactionOperation, TransactionOperationKind, TransactionRequest,
49
49
  },
50
50
  sync_contract::{
@@ -118,6 +118,8 @@ impl From<feltdb::FlowError> for ApiError {
118
118
  #[derive(Serialize)]
119
119
  struct HealthResponse<'a> {
120
120
  status: &'a str,
121
+ version: &'a str,
122
+ git_commit: &'a str,
121
123
  runtime: &'a str,
122
124
  storage: &'a str,
123
125
  fabric: &'a str,
@@ -4708,6 +4710,32 @@ async fn list_runtime_triggers(
4708
4710
  Ok(Json(json!(contract.triggers)))
4709
4711
  }
4710
4712
 
4713
+ fn state_policy_resource_matches(resource:&str,collection:&str)->bool{resource==collection||resource.strip_suffix('*').is_some_and(|prefix|!prefix.contains('*')&&collection.starts_with(prefix))}
4714
+ fn matching_state_policy<'a>(contract:&'a ApplicationRuntimeContract,collection:&str)->Option<&'a feltdb::application::PolicyDefinition>{contract.policies.definitions.iter().find(|p|p.resource==collection).or_else(||contract.policies.definitions.iter().find(|p|state_policy_resource_matches(&p.resource,collection)))}
4715
+ fn authorize_transaction_collections(
4716
+ principal: &Principal,
4717
+ contract: &ApplicationRuntimeContract,
4718
+ tenant: &str,
4719
+ collections: &[String],
4720
+ ) -> (bool, BTreeMap<String, PolicySubject>) {
4721
+ if tenant != contract.tenant_id || collections.is_empty() {
4722
+ return (false, BTreeMap::new());
4723
+ }
4724
+ let mut write_policies = BTreeMap::new();
4725
+ for collection in collections {
4726
+ let decision = state_authorization(principal, contract, collection, "write");
4727
+ if !decision.capabilities.contains("state:write") {
4728
+ return (false, BTreeMap::new());
4729
+ }
4730
+ if let Some(subject) = matching_state_policy(contract, collection)
4731
+ .and_then(|policy| policy.write.as_deref())
4732
+ .and_then(PolicySubject::from_str)
4733
+ {
4734
+ write_policies.insert(collection.clone(), subject);
4735
+ }
4736
+ }
4737
+ (true, write_policies)
4738
+ }
4711
4739
  fn state_authorization(
4712
4740
  principal: &Principal,
4713
4741
  contract: &ApplicationRuntimeContract,
@@ -4716,13 +4744,8 @@ fn state_authorization(
4716
4744
  ) -> AuthorizationContext {
4717
4745
  let subject = format!("{}:{}", principal.subject_type, principal.key_id);
4718
4746
 
4719
- // Look up policy for this collection
4720
- let policy_subject = contract
4721
- .policies
4722
- .definitions
4723
- .iter()
4724
- .find(|p| p.resource == collection)
4725
- .and_then(|p| {
4747
+ let policy=matching_state_policy(contract,collection);
4748
+ let policy_subject = policy.and_then(|p| {
4726
4749
  if operation == "read" {
4727
4750
  p.read.as_ref().and_then(|s| PolicySubject::from_str(s))
4728
4751
  } else {
@@ -4756,14 +4779,15 @@ fn state_authorization(
4756
4779
  BTreeSet::new()
4757
4780
  }
4758
4781
  }
4759
- } else {
4760
- // No policy found, use default capabilities
4782
+ } else if policy.is_some_and(|p|p.capabilities.iter().any(|capability|capability==if operation=="read"{"state:read"}else{"state:write"})) {
4761
4783
  let capability = if operation == "read" {
4762
4784
  "state:read"
4763
4785
  } else {
4764
4786
  "state:write"
4765
4787
  };
4766
4788
  [capability.into()].into()
4789
+ } else {
4790
+ BTreeSet::new()
4767
4791
  };
4768
4792
 
4769
4793
  AuthorizationContext {
@@ -5091,27 +5115,21 @@ async fn execute_canonical_transaction(
5091
5115
  input.transaction.revision_id = contract.revision_id.clone();
5092
5116
  input.transaction.schema_version = contract.state.schema.schema_version;
5093
5117
  input.transaction.state_namespace = Some(contract.environment.state_namespace.clone());
5094
- // For transactions with operations, use the first operation's collection for policy evaluation
5095
- // Multi-collection transactions will still be checked at the operation level in execute_transaction
5096
- let collection = input
5097
- .transaction
5098
- .operations
5099
- .first()
5100
- .map(|op| op.collection.clone())
5101
- .unwrap_or_default();
5102
- input.transaction.authorization =
5103
- state_authorization(&principal, &contract, &collection, "write");
5104
- let write_policy = contract
5105
- .policies
5106
- .definitions
5107
- .iter()
5108
- .find(|p| p.resource == collection)
5109
- .and_then(|p| p.write.as_ref().and_then(|s| PolicySubject::from_str(s)));
5110
- let result = execute_transaction(
5118
+ let collections = input.transaction.operations.iter()
5119
+ .map(|operation| operation.collection.clone())
5120
+ .collect::<Vec<_>>();
5121
+ let (authorized, write_policies) = authorize_transaction_collections(
5122
+ &principal,
5123
+ &contract,
5124
+ &tenant,
5125
+ &collections,
5126
+ );
5127
+ input.transaction.authorization=state_authorization_legacy(&principal,&tenant,&input.application_id,&contract.revision_id,if authorized{"state:write"}else{"state:denied"});
5128
+ let result = execute_transaction_with_collection_policies(
5111
5129
  &state.db,
5112
5130
  &contract.state.schema,
5113
5131
  &input.transaction,
5114
- write_policy,
5132
+ &write_policies,
5115
5133
  )
5116
5134
  .map_err(state_contract_error)?;
5117
5135
  audit(
@@ -6556,6 +6574,8 @@ async fn health(State(state): State<AppState>) -> Json<HealthResponse<'static>>
6556
6574
  } else {
6557
6575
  "degraded"
6558
6576
  },
6577
+ version: env!("CARGO_PKG_VERSION"),
6578
+ git_commit: option_env!("FELTDB_GIT_COMMIT").unwrap_or("unknown"),
6559
6579
  runtime: "self-hosted",
6560
6580
  storage: "durable",
6561
6581
  fabric: "healthy",
@@ -6659,12 +6679,15 @@ fn manage_keys() -> Result<bool, Box<dyn std::error::Error>> {
6659
6679
  .map(str::to_string)
6660
6680
  .collect()
6661
6681
  };
6662
- let token = store.create(
6682
+ let key = store.create(
6663
6683
  name,
6664
6684
  values("--scope", "state:read,state:write,events:read"),
6665
6685
  values("--namespace", "default"),
6666
6686
  )?;
6667
- println!("API key created. This secret will not be shown again:\n{token}");
6687
+ println!(
6688
+ "API key created (id: {}). This secret will not be shown again:\n{}",
6689
+ key.id, key.secret
6690
+ );
6668
6691
  }
6669
6692
  "list" => println!("{}", serde_json::to_string_pretty(&store.list()?)?),
6670
6693
  "revoke" => {
@@ -9088,3 +9111,92 @@ async fn shutdown_signal() {
9088
9111
  let terminate = std::future::pending::<()>();
9089
9112
  tokio::select! { _ = ctrl_c => {}, _ = terminate => {} }
9090
9113
  }
9114
+
9115
+ #[cfg(test)]
9116
+ mod authority_gate_tests {
9117
+ use super::{authorize_transaction_collections, state_authorization};
9118
+ use feltdb::{
9119
+ application::{manifest_hash, ApplicationManifest, ApplicationRevision, PolicyDefinition, RevisionStatus},
9120
+ application_runtime::{resolve_runtime, ApplicationRuntimeContract, RuntimeInventory},
9121
+ };
9122
+ use feltdb_server::auth::Principal;
9123
+
9124
+ fn contract(policies: Vec<PolicyDefinition>) -> ApplicationRuntimeContract {
9125
+ let mut manifest = ApplicationManifest::empty("tenant", "app", "Authority test");
9126
+ manifest.policies = policies;
9127
+ let revision = ApplicationRevision {
9128
+ revision_id: "apprev://tenant/app/test".into(),
9129
+ application_id: "app".into(),
9130
+ tenant_id: "tenant".into(),
9131
+ revision_number: 1,
9132
+ parent_revision_id: None,
9133
+ manifest_hash: manifest_hash(&manifest).unwrap(),
9134
+ manifest,
9135
+ created_by: "test".into(),
9136
+ created_at: 1,
9137
+ status: RevisionStatus::Committed,
9138
+ };
9139
+ resolve_runtime(&revision, "production", &RuntimeInventory::default()).unwrap()
9140
+ }
9141
+
9142
+ fn principal() -> Principal {
9143
+ Principal {
9144
+ key_id: "key-1".into(),
9145
+ scopes: vec!["state:read".into(), "state:write".into()],
9146
+ subject_type: "api_key".into(),
9147
+ identity_id: None,
9148
+ session_id: None,
9149
+ }
9150
+ }
9151
+
9152
+ fn allow(resource: &str) -> PolicyDefinition {
9153
+ PolicyDefinition {
9154
+ name: resource.into(),
9155
+ resource: resource.into(),
9156
+ read: None,
9157
+ write: None,
9158
+ capabilities: vec!["state:read".into(), "state:write".into()],
9159
+ }
9160
+ }
9161
+
9162
+ #[test]
9163
+ fn no_matching_policy_denies() {
9164
+ let contract = contract(vec![]);
9165
+ assert!(state_authorization(&principal(), &contract, "secrets", "read").capabilities.is_empty());
9166
+ let (allowed, _) = authorize_transaction_collections(
9167
+ &principal(), &contract, "tenant", &["secrets".into()],
9168
+ );
9169
+ assert!(!allowed);
9170
+ }
9171
+
9172
+ #[test]
9173
+ fn terminal_wildcard_policy_allows_matching_collection() {
9174
+ let contract = contract(vec![allow("buzz_*")]);
9175
+ let (allowed, _) = authorize_transaction_collections(
9176
+ &principal(), &contract, "tenant", &["buzz_rooms".into()],
9177
+ );
9178
+ assert!(allowed);
9179
+ }
9180
+
9181
+ #[test]
9182
+ fn multi_collection_transaction_authorizes_every_operation() {
9183
+ let contract = contract(vec![allow("alpha"), allow("beta")]);
9184
+ let (all_allowed, _) = authorize_transaction_collections(
9185
+ &principal(), &contract, "tenant", &["alpha".into(), "beta".into()],
9186
+ );
9187
+ let (mixed_allowed, _) = authorize_transaction_collections(
9188
+ &principal(), &contract, "tenant", &["alpha".into(), "secrets".into()],
9189
+ );
9190
+ assert!(all_allowed);
9191
+ assert!(!mixed_allowed);
9192
+ }
9193
+
9194
+ #[test]
9195
+ fn cross_tenant_transaction_denies() {
9196
+ let contract = contract(vec![allow("alpha")]);
9197
+ let (allowed, _) = authorize_transaction_collections(
9198
+ &principal(), &contract, "other-tenant", &["alpha".into()],
9199
+ );
9200
+ assert!(!allowed);
9201
+ }
9202
+ }
package/package.json CHANGED
@@ -2,7 +2,7 @@
2
2
  "name": "create-feltdb",
3
3
  "private": false,
4
4
  "description": "Create a new FeltDB application with one command",
5
- "version": "0.5.2",
5
+ "version": "0.5.4",
6
6
  "license": "MIT",
7
7
  "bin": {
8
8
  "create-feltdb": "bin/create-feltdb.js"