@feltdb/core 0.5.7 → 0.6.1

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 (49) hide show
  1. package/dist/cli/commands.js +110 -5
  2. package/dist/cli/index.js +1 -1
  3. package/dist/cli/workspace-integration.js +127 -0
  4. package/dist/create/cli.js +1 -1
  5. package/dist/create/create.js +21 -0
  6. package/dist/create/package-versions.js +1 -1
  7. package/dist/create/server-source/Cargo.lock +10 -0
  8. package/dist/create/server-source/crates/feltdb-server/Cargo.toml +1 -0
  9. package/dist/create/server-source/crates/feltdb-server/src/app_state.rs +5 -1
  10. package/dist/create/server-source/crates/feltdb-server/src/authenticated_principal.rs +273 -0
  11. package/dist/create/server-source/crates/feltdb-server/src/certification_harness.rs +528 -0
  12. package/dist/create/server-source/crates/feltdb-server/src/delegation_token.rs +472 -0
  13. package/dist/create/server-source/crates/feltdb-server/src/durable_operations.rs +992 -0
  14. package/dist/create/server-source/crates/feltdb-server/src/lib.rs +9 -0
  15. package/dist/create/server-source/crates/feltdb-server/src/main.rs +157 -9
  16. package/dist/create/server-source/crates/feltdb-server/src/managed_diagnostics.rs +413 -0
  17. package/dist/create/server-source/crates/feltdb-server/src/membership_policy.rs +488 -0
  18. package/dist/create/server-source/crates/feltdb-server/src/snapshot_cursor.rs +378 -0
  19. package/dist/create/server-source/crates/feltdb-server/src/tenancy.rs +49 -0
  20. package/dist/create/server-source/crates/feltdb-server/src/tenant_policies.rs +525 -0
  21. package/dist/create/server-source/crates/feltdb-server/src/transaction_recovery.rs +461 -0
  22. package/dist/create/template/dot-feltdb-README.md +58 -0
  23. package/dist/create/workspace-initialization.js +77 -0
  24. package/dist/index.d.ts +6 -0
  25. package/dist/index.d.ts.map +1 -1
  26. package/dist/index.js +6 -0
  27. package/dist/studio-app/assets/{feltdb_wasm-h9mxesnH.js → feltdb_wasm-B4wq4mqp.js} +1 -1
  28. package/dist/studio-app/assets/feltdb_wasm_bg-Ceyi7l21.wasm +0 -0
  29. package/dist/studio-app/assets/{index-B_EMnTaE.js → index-LQmvJSq6.js} +2 -2
  30. package/dist/studio-app/index.html +1 -1
  31. package/dist/telemetry.js +1 -1
  32. package/dist/wasm/feltdb_wasm_bg.wasm +0 -0
  33. package/dist/workspace/development-node.d.ts +42 -0
  34. package/dist/workspace/development-node.d.ts.map +1 -0
  35. package/dist/workspace/development-node.js +208 -0
  36. package/dist/workspace/index.d.ts +20 -0
  37. package/dist/workspace/index.d.ts.map +1 -0
  38. package/dist/workspace/index.js +16 -0
  39. package/dist/workspace/workspace-connection.d.ts +174 -0
  40. package/dist/workspace/workspace-connection.d.ts.map +1 -0
  41. package/dist/workspace/workspace-connection.js +290 -0
  42. package/dist/workspace/workspace-identity.d.ts +13 -0
  43. package/dist/workspace/workspace-identity.d.ts.map +1 -0
  44. package/dist/workspace/workspace-identity.js +70 -0
  45. package/dist/workspace/workspace-types.d.ts +82 -0
  46. package/dist/workspace/workspace-types.d.ts.map +1 -0
  47. package/dist/workspace/workspace-types.js +7 -0
  48. package/package.json +5 -1
  49. package/dist/studio-app/assets/feltdb_wasm_bg-B8U4A1n1.wasm +0 -0
@@ -0,0 +1,378 @@
1
+ use base64::Engine;
2
+ use serde::{Deserialize, Serialize};
3
+ use sha2::{Digest, Sha256};
4
+ use std::time::{SystemTime, UNIX_EPOCH};
5
+
6
+ /// Snapshot-bound pagination cursor.
7
+ /// Prevents reading from different snapshots within a single pagination session.
8
+ #[derive(Debug, Clone, Serialize, Deserialize)]
9
+ pub struct SnapshotCursor {
10
+ /// Unique identifier for the snapshot version.
11
+ pub snapshot_id: String,
12
+
13
+ /// Position/offset within the snapshot.
14
+ pub position: usize,
15
+
16
+ /// Collection being paginated.
17
+ pub collection: String,
18
+
19
+ /// Snapshot version number.
20
+ pub snapshot_version: u64,
21
+
22
+ /// Hash of authorization context (prevents reuse with different auth).
23
+ pub auth_context_hash: String,
24
+
25
+ /// When this cursor was created.
26
+ pub created_at: u64,
27
+
28
+ /// Hash of the cursor payload for verification.
29
+ pub checksum: String,
30
+ }
31
+
32
+ /// Pagination request parameters.
33
+ #[derive(Debug, Clone, Serialize, Deserialize)]
34
+ pub struct PaginationRequest {
35
+ pub collection: String,
36
+ pub limit: usize,
37
+ pub cursor: Option<String>, // Base64-encoded SnapshotCursor
38
+ }
39
+
40
+ /// Pagination response.
41
+ #[derive(Debug, Clone, Serialize, Deserialize)]
42
+ pub struct PaginationResponse<T> {
43
+ pub records: Vec<T>,
44
+ pub next_cursor: Option<String>, // Base64-encoded cursor or None if end
45
+ pub snapshot_version: u64,
46
+ pub total_returned: usize,
47
+ }
48
+
49
+ /// Pagination cursor codec.
50
+ pub struct CursorCodec;
51
+
52
+ impl CursorCodec {
53
+ /// Encodes a cursor to a base64 string.
54
+ pub fn encode(cursor: &SnapshotCursor) -> Result<String, String> {
55
+ let json = serde_json::to_string(cursor)
56
+ .map_err(|e| format!("failed to serialize cursor: {e}"))?;
57
+ Ok(base64::engine::general_purpose::STANDARD.encode(json))
58
+ }
59
+
60
+ /// Decodes a cursor from a base64 string.
61
+ pub fn decode(encoded: &str) -> Result<SnapshotCursor, String> {
62
+ let bytes = base64::engine::general_purpose::STANDARD
63
+ .decode(encoded)
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}"))?;
67
+ serde_json::from_str(&json).map_err(|e| format!("invalid cursor JSON: {e}"))
68
+ }
69
+ }
70
+
71
+ /// Snapshot cursor factory and validator.
72
+ pub struct SnapshotCursorManager {
73
+ /// Expected collection (prevents cursor reuse across collections).
74
+ expected_collection: String,
75
+ }
76
+
77
+ impl SnapshotCursorManager {
78
+ pub fn new(collection: String) -> Self {
79
+ Self {
80
+ expected_collection: collection,
81
+ }
82
+ }
83
+
84
+ /// Creates a new cursor for pagination.
85
+ pub fn create_cursor(
86
+ &self,
87
+ snapshot_id: String,
88
+ snapshot_version: u64,
89
+ position: usize,
90
+ auth_context_hash: String,
91
+ ) -> Result<String, String> {
92
+ let cursor = SnapshotCursor {
93
+ snapshot_id: snapshot_id.clone(),
94
+ position,
95
+ collection: self.expected_collection.clone(),
96
+ snapshot_version,
97
+ auth_context_hash: auth_context_hash.clone(),
98
+ created_at: now(),
99
+ checksum: String::new(), // Will be computed below
100
+ };
101
+
102
+ let checksum = self.compute_checksum(&cursor)?;
103
+ let cursor_with_checksum = SnapshotCursor {
104
+ checksum,
105
+ ..cursor
106
+ };
107
+
108
+ CursorCodec::encode(&cursor_with_checksum)
109
+ }
110
+
111
+ /// Validates a cursor.
112
+ pub fn validate_cursor(
113
+ &self,
114
+ encoded_cursor: &str,
115
+ current_snapshot_id: &str,
116
+ current_snapshot_version: u64,
117
+ current_auth_context_hash: &str,
118
+ ) -> Result<SnapshotCursor, String> {
119
+ let cursor = CursorCodec::decode(encoded_cursor)?;
120
+
121
+ // Verify collection
122
+ if cursor.collection != self.expected_collection {
123
+ return Err(format!(
124
+ "cursor collection mismatch: expected {}, got {}",
125
+ self.expected_collection, cursor.collection
126
+ ));
127
+ }
128
+
129
+ // Verify snapshot hasn't changed
130
+ if cursor.snapshot_id != current_snapshot_id {
131
+ return Err(format!(
132
+ "snapshot changed: cursor has {} but current is {}",
133
+ cursor.snapshot_id, current_snapshot_id
134
+ ));
135
+ }
136
+
137
+ // Verify snapshot version consistency
138
+ if cursor.snapshot_version != current_snapshot_version {
139
+ return Err(format!(
140
+ "snapshot version mismatch: cursor version {}, current version {}",
141
+ cursor.snapshot_version, current_snapshot_version
142
+ ));
143
+ }
144
+
145
+ // Verify authorization context hasn't changed
146
+ if cursor.auth_context_hash != current_auth_context_hash {
147
+ return Err(
148
+ "authorization context changed since cursor creation".into(),
149
+ );
150
+ }
151
+
152
+ // Verify checksum
153
+ self.verify_checksum(&cursor)?;
154
+
155
+ Ok(cursor)
156
+ }
157
+
158
+ /// Gets the next position for pagination.
159
+ pub fn next_position(&self, cursor: &SnapshotCursor, returned_count: usize) -> usize {
160
+ cursor.position + returned_count
161
+ }
162
+
163
+ /// Checks if this is likely the end of results.
164
+ pub fn is_end(&self, returned_count: usize, limit: usize) -> bool {
165
+ returned_count < limit
166
+ }
167
+
168
+ /// Computes hash of authorization context.
169
+ pub fn hash_auth_context(actor_id: &str, tenant_id: &str, required_role: Option<&str>) -> String {
170
+ let mut hasher = Sha256::new();
171
+ hasher.update(actor_id.as_bytes());
172
+ hasher.update(tenant_id.as_bytes());
173
+ if let Some(role) = required_role {
174
+ hasher.update(role.as_bytes());
175
+ }
176
+ format!("{:x}", hasher.finalize())
177
+ }
178
+
179
+ fn compute_checksum(&self, cursor: &SnapshotCursor) -> Result<String, String> {
180
+ let mut hasher = Sha256::new();
181
+
182
+ hasher.update(cursor.snapshot_id.as_bytes());
183
+ hasher.update(cursor.position.to_le_bytes());
184
+ hasher.update(cursor.collection.as_bytes());
185
+ hasher.update(cursor.snapshot_version.to_le_bytes());
186
+ hasher.update(cursor.auth_context_hash.as_bytes());
187
+ hasher.update(cursor.created_at.to_le_bytes());
188
+
189
+ Ok(format!("{:x}", hasher.finalize()))
190
+ }
191
+
192
+ fn verify_checksum(&self, cursor: &SnapshotCursor) -> Result<(), String> {
193
+ let mut cursor_copy = cursor.clone();
194
+ let expected_checksum = cursor_copy.checksum.clone();
195
+ cursor_copy.checksum = String::new();
196
+
197
+ let computed = self.compute_checksum(&cursor_copy)?;
198
+ if computed != expected_checksum {
199
+ return Err("cursor checksum verification failed".into());
200
+ }
201
+
202
+ Ok(())
203
+ }
204
+ }
205
+
206
+ fn now() -> u64 {
207
+ SystemTime::now()
208
+ .duration_since(UNIX_EPOCH)
209
+ .unwrap_or_default()
210
+ .as_secs()
211
+ }
212
+
213
+ #[cfg(test)]
214
+ mod tests {
215
+ use super::*;
216
+
217
+ #[test]
218
+ fn encode_and_decode_cursor() {
219
+ let cursor = SnapshotCursor {
220
+ snapshot_id: "snap_123".into(),
221
+ position: 100,
222
+ collection: "release_jobs".into(),
223
+ snapshot_version: 5,
224
+ auth_context_hash: "hash_abc".into(),
225
+ created_at: 1234567890,
226
+ checksum: "checksum_xyz".into(),
227
+ };
228
+
229
+ let encoded = CursorCodec::encode(&cursor).expect("encode failed");
230
+ let decoded = CursorCodec::decode(&encoded).expect("decode failed");
231
+
232
+ assert_eq!(decoded.snapshot_id, cursor.snapshot_id);
233
+ assert_eq!(decoded.position, cursor.position);
234
+ assert_eq!(decoded.collection, cursor.collection);
235
+ }
236
+
237
+ #[test]
238
+ fn create_and_validate_cursor() {
239
+ let manager = SnapshotCursorManager::new("release_jobs".into());
240
+ let auth_hash = SnapshotCursorManager::hash_auth_context("user_123", "tenant_prod", None);
241
+
242
+ let encoded = manager
243
+ .create_cursor("snap_123".into(), 5, 0, auth_hash.clone())
244
+ .expect("create failed");
245
+
246
+ let validated = manager
247
+ .validate_cursor(&encoded, "snap_123", 5, &auth_hash)
248
+ .expect("validate failed");
249
+
250
+ assert_eq!(validated.position, 0);
251
+ assert_eq!(validated.snapshot_version, 5);
252
+ }
253
+
254
+ #[test]
255
+ fn reject_cursor_from_different_collection() {
256
+ let manager = SnapshotCursorManager::new("release_jobs".into());
257
+ let auth_hash = SnapshotCursorManager::hash_auth_context("user_123", "tenant_prod", None);
258
+
259
+ let cursor = SnapshotCursor {
260
+ snapshot_id: "snap_123".into(),
261
+ position: 100,
262
+ collection: "workflows".into(), // Wrong collection
263
+ snapshot_version: 5,
264
+ auth_context_hash: auth_hash.clone(),
265
+ created_at: now(),
266
+ checksum: "checksum_xyz".into(),
267
+ };
268
+
269
+ let encoded = CursorCodec::encode(&cursor).expect("encode failed");
270
+
271
+ let result = manager.validate_cursor(&encoded, "snap_123", 5, &auth_hash);
272
+ assert!(result.is_err());
273
+ }
274
+
275
+ #[test]
276
+ fn reject_cursor_when_snapshot_changes() {
277
+ let manager = SnapshotCursorManager::new("release_jobs".into());
278
+ let auth_hash = SnapshotCursorManager::hash_auth_context("user_123", "tenant_prod", None);
279
+
280
+ let encoded = manager
281
+ .create_cursor("snap_123".into(), 5, 100, auth_hash.clone())
282
+ .expect("create failed");
283
+
284
+ // Try to use with different snapshot
285
+ let result = manager.validate_cursor(&encoded, "snap_456", 5, &auth_hash);
286
+ assert!(result.is_err());
287
+ }
288
+
289
+ #[test]
290
+ fn reject_cursor_when_auth_context_changes() {
291
+ 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);
296
+
297
+ let encoded = manager
298
+ .create_cursor("snap_123".into(), 5, 0, auth_hash1)
299
+ .expect("create failed");
300
+
301
+ // Try to use with different auth context
302
+ let result = manager.validate_cursor(&encoded, "snap_123", 5, &auth_hash2);
303
+ assert!(result.is_err());
304
+ }
305
+
306
+ #[test]
307
+ fn compute_next_position() {
308
+ let manager = SnapshotCursorManager::new("release_jobs".into());
309
+ let cursor = SnapshotCursor {
310
+ snapshot_id: "snap_123".into(),
311
+ position: 100,
312
+ collection: "release_jobs".into(),
313
+ snapshot_version: 5,
314
+ auth_context_hash: "hash_abc".into(),
315
+ created_at: now(),
316
+ checksum: "checksum_xyz".into(),
317
+ };
318
+
319
+ let next = manager.next_position(&cursor, 50);
320
+ assert_eq!(next, 150);
321
+ }
322
+
323
+ #[test]
324
+ fn detect_end_of_results() {
325
+ let manager = SnapshotCursorManager::new("release_jobs".into());
326
+
327
+ // Returned less than limit = end
328
+ assert!(manager.is_end(10, 20));
329
+ // Returned equals limit = might have more
330
+ assert!(!manager.is_end(20, 20));
331
+ // Returned 0 = definitely end
332
+ assert!(manager.is_end(0, 20));
333
+ }
334
+
335
+ #[test]
336
+ fn auth_context_hash_includes_all_components() {
337
+ let hash1 = SnapshotCursorManager::hash_auth_context("user_123", "tenant_prod", None);
338
+ let hash2 =
339
+ SnapshotCursorManager::hash_auth_context("user_123", "tenant_prod", Some("admin"));
340
+ let hash3 = SnapshotCursorManager::hash_auth_context("user_456", "tenant_prod", None);
341
+
342
+ // Different role = different hash
343
+ assert_ne!(hash1, hash2);
344
+ // Different actor = different hash
345
+ assert_ne!(hash1, hash3);
346
+ // Same inputs = same hash
347
+ let hash1_repeat =
348
+ SnapshotCursorManager::hash_auth_context("user_123", "tenant_prod", None);
349
+ assert_eq!(hash1, hash1_repeat);
350
+ }
351
+
352
+ #[test]
353
+ fn invalid_base64_rejected() {
354
+ let result = CursorCodec::decode("not valid base64!!!");
355
+ assert!(result.is_err());
356
+ }
357
+
358
+ #[test]
359
+ fn tampered_checksum_rejected() {
360
+ let manager = SnapshotCursorManager::new("release_jobs".into());
361
+ let auth_hash = SnapshotCursorManager::hash_auth_context("user_123", "tenant_prod", None);
362
+
363
+ let cursor = SnapshotCursor {
364
+ snapshot_id: "snap_123".into(),
365
+ position: 100,
366
+ collection: "release_jobs".into(),
367
+ snapshot_version: 5,
368
+ auth_context_hash: auth_hash.clone(),
369
+ created_at: now(),
370
+ checksum: "fake_checksum".into(),
371
+ };
372
+
373
+ let encoded = CursorCodec::encode(&cursor).expect("encode failed");
374
+
375
+ let result = manager.validate_cursor(&encoded, "snap_123", 5, &auth_hash);
376
+ assert!(result.is_err());
377
+ }
378
+ }
@@ -1,4 +1,5 @@
1
1
  use std::{
2
+ collections::BTreeSet,
2
3
  fs,
3
4
  path::PathBuf,
4
5
  sync::{Arc, RwLock},
@@ -578,6 +579,54 @@ impl TenancyStore {
578
579
  Ok(tenant)
579
580
  }
580
581
 
582
+ /// Removes an isolated certification tenant selected by an exact application ID.
583
+ /// This deliberately refuses non-certification resources and avoids global inventory access.
584
+ pub fn delete_certification_fixture(&self, application_id: &str) -> Result<String, String> {
585
+ let mut records = self
586
+ .records
587
+ .write()
588
+ .map_err(|_| "tenancy store lock poisoned")?;
589
+ let application = records
590
+ .applications
591
+ .iter()
592
+ .find(|application| application.id == application_id)
593
+ .cloned()
594
+ .ok_or("application not found")?;
595
+ let tenant = records
596
+ .tenants
597
+ .iter()
598
+ .find(|tenant| tenant.id == application.tenant_id)
599
+ .cloned()
600
+ .ok_or("tenant not found")?;
601
+ if !application.name.to_lowercase().contains("certification")
602
+ || !tenant.name.to_lowercase().contains("certification")
603
+ {
604
+ return Err("refusing to delete a non-certification fixture".into());
605
+ }
606
+ let tenant_id = tenant.id.clone();
607
+ let application_ids = records
608
+ .applications
609
+ .iter()
610
+ .filter(|candidate| candidate.tenant_id == tenant_id)
611
+ .map(|candidate| candidate.id.clone())
612
+ .collect::<BTreeSet<_>>();
613
+ records
614
+ .applications
615
+ .retain(|candidate| candidate.tenant_id != tenant_id);
616
+ records
617
+ .application_memberships
618
+ .retain(|membership| !application_ids.contains(&membership.application_id));
619
+ records
620
+ .tenant_memberships
621
+ .retain(|membership| membership.tenant_id != tenant_id);
622
+ records
623
+ .invitations
624
+ .retain(|invitation| invitation.tenant_id != tenant_id);
625
+ records.tenants.retain(|candidate| candidate.id != tenant_id);
626
+ self.persist(&records)?;
627
+ Ok(tenant_id)
628
+ }
629
+
581
630
  pub fn create_application(
582
631
  &self,
583
632
  actor: &str,