@feltdb/core 0.5.6 → 0.6.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 (48) hide show
  1. package/dist/cli/commands.js +68 -1
  2. package/dist/cli/workspace-integration.js +96 -0
  3. package/dist/create/cli.js +1 -1
  4. package/dist/create/create.js +21 -0
  5. package/dist/create/package-versions.js +1 -1
  6. package/dist/create/server-source/Cargo.lock +10 -0
  7. package/dist/create/server-source/crates/feltdb-server/Cargo.toml +1 -0
  8. package/dist/create/server-source/crates/feltdb-server/src/app_state.rs +5 -1
  9. package/dist/create/server-source/crates/feltdb-server/src/authenticated_principal.rs +273 -0
  10. package/dist/create/server-source/crates/feltdb-server/src/certification_harness.rs +528 -0
  11. package/dist/create/server-source/crates/feltdb-server/src/delegation_token.rs +472 -0
  12. package/dist/create/server-source/crates/feltdb-server/src/durable_operations.rs +992 -0
  13. package/dist/create/server-source/crates/feltdb-server/src/lib.rs +9 -0
  14. package/dist/create/server-source/crates/feltdb-server/src/main.rs +157 -9
  15. package/dist/create/server-source/crates/feltdb-server/src/managed_diagnostics.rs +413 -0
  16. package/dist/create/server-source/crates/feltdb-server/src/membership_policy.rs +488 -0
  17. package/dist/create/server-source/crates/feltdb-server/src/snapshot_cursor.rs +378 -0
  18. package/dist/create/server-source/crates/feltdb-server/src/tenancy.rs +49 -0
  19. package/dist/create/server-source/crates/feltdb-server/src/tenant_policies.rs +525 -0
  20. package/dist/create/server-source/crates/feltdb-server/src/transaction_recovery.rs +461 -0
  21. package/dist/create/template/dot-feltdb-README.md +58 -0
  22. package/dist/create/workspace-initialization.js +77 -0
  23. package/dist/index.d.ts +6 -0
  24. package/dist/index.d.ts.map +1 -1
  25. package/dist/index.js +6 -0
  26. package/dist/studio-app/assets/{feltdb_wasm-h9mxesnH.js → feltdb_wasm-B4wq4mqp.js} +1 -1
  27. package/dist/studio-app/assets/feltdb_wasm_bg-Ceyi7l21.wasm +0 -0
  28. package/dist/studio-app/assets/{index-B_EMnTaE.js → index-LQmvJSq6.js} +2 -2
  29. package/dist/studio-app/index.html +1 -1
  30. package/dist/telemetry.js +1 -1
  31. package/dist/wasm/feltdb_wasm_bg.wasm +0 -0
  32. package/dist/workspace/development-node.d.ts +42 -0
  33. package/dist/workspace/development-node.d.ts.map +1 -0
  34. package/dist/workspace/development-node.js +208 -0
  35. package/dist/workspace/index.d.ts +20 -0
  36. package/dist/workspace/index.d.ts.map +1 -0
  37. package/dist/workspace/index.js +16 -0
  38. package/dist/workspace/workspace-connection.d.ts +174 -0
  39. package/dist/workspace/workspace-connection.d.ts.map +1 -0
  40. package/dist/workspace/workspace-connection.js +290 -0
  41. package/dist/workspace/workspace-identity.d.ts +13 -0
  42. package/dist/workspace/workspace-identity.d.ts.map +1 -0
  43. package/dist/workspace/workspace-identity.js +70 -0
  44. package/dist/workspace/workspace-types.d.ts +82 -0
  45. package/dist/workspace/workspace-types.d.ts.map +1 -0
  46. package/dist/workspace/workspace-types.js +7 -0
  47. package/package.json +5 -1
  48. package/dist/studio-app/assets/feltdb_wasm_bg-B8U4A1n1.wasm +0 -0
@@ -8,6 +8,7 @@ import net from 'net';
8
8
  import { createRequire } from 'module';
9
9
  import { spawn, spawnSync } from 'child_process';
10
10
  import { createFeltDB, diffFlowSpec, formatFlowSpec, parseFlowSpec, planFlowSpecMigration, validateFlowSpec } from '@feltdb/core';
11
+ import { discoverWorkspace, generatePairingToken, persistPairingToken, displayWorkspaceStatus } from './workspace-integration.js';
11
12
  function loadProjectEnvironment(file = path.resolve('.env.local')) {
12
13
  if (!fs.existsSync(file))
13
14
  return;
@@ -49,6 +50,8 @@ export async function handleCommand(command, args) {
49
50
  return handleConnect(args);
50
51
  case 'status':
51
52
  return handleStatus();
53
+ case 'workspace':
54
+ return handleWorkspace(args);
52
55
  case 'dev':
53
56
  return handleDev(args);
54
57
  case 'build':
@@ -365,6 +368,51 @@ async function handleStatus() {
365
368
  console.log('Workflows: 5');
366
369
  console.log('Health: ✓\n');
367
370
  }
371
+ async function handleWorkspace(args) {
372
+ const subcommand = args[0] || 'status';
373
+ switch (subcommand) {
374
+ case 'status':
375
+ return handleWorkspaceStatus();
376
+ case 'info':
377
+ return handleWorkspaceInfo();
378
+ case 'connect':
379
+ return handleWorkspaceConnect(args.slice(1));
380
+ default:
381
+ console.error(`Unknown workspace subcommand: ${subcommand}`);
382
+ process.exit(1);
383
+ }
384
+ }
385
+ async function handleWorkspaceStatus() {
386
+ displayWorkspaceStatus(process.cwd());
387
+ }
388
+ async function handleWorkspaceInfo() {
389
+ const workspace = discoverWorkspace(process.cwd());
390
+ if (!workspace) {
391
+ console.log('❌ No Development Workspace found');
392
+ console.log(' Run "feltdb dev" to initialize the workspace');
393
+ return;
394
+ }
395
+ console.log('\n📋 Workspace Information\n');
396
+ console.log(` Workspace ID: ${workspace.workspaceId}`);
397
+ console.log(` Project: ${workspace.projectId}`);
398
+ console.log(` Version: ${workspace.version}`);
399
+ console.log(` Location: .feltdb/workspace.json\n`);
400
+ console.log(' This workspace enables:');
401
+ console.log(' • Browser extensions to discover and connect');
402
+ console.log(' • IDE integrations to share state');
403
+ console.log(' • Agents to coordinate through the same interface');
404
+ console.log(' • CLI to manage workspace discovery\n');
405
+ }
406
+ async function handleWorkspaceConnect(args) {
407
+ const workspaceId = args[0];
408
+ if (!workspaceId) {
409
+ console.error('Usage: feltdb workspace connect <workspace-id>');
410
+ process.exit(1);
411
+ }
412
+ console.log(`🔗 Connecting to workspace: ${workspaceId}`);
413
+ console.log(' This feature will be implemented in the next phase');
414
+ console.log(' (when @feltdb/client workspace APIs are available)\n');
415
+ }
368
416
  function runLocalVite(args, waitForExit) {
369
417
  const executable = process.platform === 'win32' ? 'npm.cmd' : 'npm';
370
418
  const child = spawn(executable, ['exec', '--', 'vite', ...args], {
@@ -432,11 +480,30 @@ async function handleDev(args) {
432
480
  selfHostedStarted = true;
433
481
  (_c = process.env).VITE_FELTDB_URL || (_c.VITE_FELTDB_URL = 'http://127.0.0.1:7700');
434
482
  }
483
+ // Initialize Development Workspace
484
+ // This enables all FeltDB-aware tools to discover and connect to the same workspace
485
+ const workspace = discoverWorkspace(process.cwd());
486
+ let pairingToken = null;
487
+ if (workspace) {
488
+ const token = generatePairingToken();
489
+ token.workspaceId = workspace.workspaceId;
490
+ persistPairingToken(process.cwd(), token);
491
+ pairingToken = token.token;
492
+ }
435
493
  console.log('FeltDB Dev Server');
436
494
  console.log(` Namespace: ${config.namespace}`);
437
495
  console.log(` Runtime: ${config.runtime}`);
438
496
  console.log(` Storage: ${config.storage}`);
439
- console.log(` Distributed: ${config.distributed}\n`);
497
+ console.log(` Distributed: ${config.distributed}`);
498
+ if (workspace) {
499
+ console.log(` Workspace: ${workspace.workspaceId}\n`);
500
+ console.log(`🔗 Development Workspace`);
501
+ if (pairingToken) {
502
+ console.log(` Pairing Code: ${pairingToken}`);
503
+ console.log(` Clients can use this code to auto-discover the workspace`);
504
+ }
505
+ }
506
+ console.log();
440
507
  const open = !args.includes('--no-open');
441
508
  console.log(`Application: http://127.0.0.1:${appPort}`);
442
509
  console.log(`Studio: http://127.0.0.1:${studioPort}\n`);
@@ -0,0 +1,96 @@
1
+ /**
2
+ * FeltDB Development Workspace Integration
3
+ *
4
+ * Integrates Development Workspace support into the CLI:
5
+ * - Discovers workspace identity from .feltdb/workspace.json
6
+ * - Launches Development Workspace authority via getDevelopmentNode()
7
+ * - Manages pairing tokens for browser/IDE discovery
8
+ * - Provides workspace status and connection information
9
+ */
10
+ import fs from 'fs';
11
+ import path from 'path';
12
+ import { randomBytes } from 'crypto';
13
+ /**
14
+ * Discover workspace identity from .feltdb/workspace.json
15
+ * Returns null if workspace is not initialized
16
+ */
17
+ export function discoverWorkspace(projectDir) {
18
+ const workspacePath = path.join(projectDir, '.feltdb', 'workspace.json');
19
+ if (!fs.existsSync(workspacePath)) {
20
+ return null;
21
+ }
22
+ try {
23
+ const content = fs.readFileSync(workspacePath, 'utf-8');
24
+ return JSON.parse(content);
25
+ }
26
+ catch (error) {
27
+ console.error('Failed to read workspace.json:', error);
28
+ return null;
29
+ }
30
+ }
31
+ /**
32
+ * Generate a short-lived pairing token for browser/IDE discovery
33
+ * Format: FELT-XXXX (6 random alphanumeric characters)
34
+ * Expires in 15 minutes
35
+ */
36
+ export function generatePairingToken() {
37
+ const randomPart = randomBytes(4).toString('hex').toUpperCase().slice(0, 6);
38
+ const token = `FELT-${randomPart}`;
39
+ const expiresAt = Date.now() + 15 * 60 * 1000; // 15 minutes
40
+ return { token, workspaceId: '', expiresAt };
41
+ }
42
+ /**
43
+ * Persist pairing token to .feltdb/pairing.json
44
+ * This file should be gitignored but is readable by clients
45
+ */
46
+ export function persistPairingToken(projectDir, token) {
47
+ const feltdbDir = path.join(projectDir, '.feltdb');
48
+ if (!fs.existsSync(feltdbDir)) {
49
+ fs.mkdirSync(feltdbDir, { recursive: true });
50
+ }
51
+ const pairingPath = path.join(feltdbDir, 'pairing.json');
52
+ fs.writeFileSync(pairingPath, JSON.stringify(token, null, 2) + '\n');
53
+ }
54
+ /**
55
+ * Read pairing token from .feltdb/pairing.json
56
+ * Returns null if token doesn't exist or has expired
57
+ */
58
+ export function readPairingToken(projectDir) {
59
+ const pairingPath = path.join(projectDir, '.feltdb', 'pairing.json');
60
+ if (!fs.existsSync(pairingPath)) {
61
+ return null;
62
+ }
63
+ try {
64
+ const content = fs.readFileSync(pairingPath, 'utf-8');
65
+ const token = JSON.parse(content);
66
+ // Check if token has expired
67
+ if (token.expiresAt && Date.now() > token.expiresAt) {
68
+ return null;
69
+ }
70
+ return token;
71
+ }
72
+ catch (error) {
73
+ return null;
74
+ }
75
+ }
76
+ /**
77
+ * Display workspace status information
78
+ */
79
+ export function displayWorkspaceStatus(projectDir) {
80
+ const workspace = discoverWorkspace(projectDir);
81
+ if (!workspace) {
82
+ console.log('❌ No Development Workspace found');
83
+ console.log(' Run "feltdb dev" to initialize the workspace');
84
+ return;
85
+ }
86
+ const pairingToken = readPairingToken(projectDir);
87
+ console.log('\n🔗 FeltDB Development Workspace\n');
88
+ console.log(` Project: ${workspace.projectId}`);
89
+ console.log(` Workspace: ${workspace.workspaceId}`);
90
+ if (pairingToken) {
91
+ console.log(` Pairing: ${pairingToken.token}`);
92
+ const minutesRemaining = Math.ceil((pairingToken.expiresAt - Date.now()) / 60000);
93
+ console.log(` Expires in: ${minutesRemaining} minutes`);
94
+ }
95
+ console.log('\n Location: .feltdb/workspace.json\n');
96
+ }
@@ -307,7 +307,7 @@ Learn more: https://github.com/rkendel1/feltdb`);
307
307
  await createProject({
308
308
  projectName,
309
309
  autoYes: shouldAutoYes,
310
- templatesDir: path.join(__dirname, '../templates'),
310
+ templatesDir: path.join(__dirname, '../template'),
311
311
  runtime: options.runtime,
312
312
  framework: options.framework,
313
313
  distributed: options.distributed,
@@ -6,6 +6,7 @@ import path from 'path';
6
6
  import { randomBytes } from 'crypto';
7
7
  import { feltdbPackageRange } from './package-versions.js';
8
8
  import { generateDockerCompose, generateDockerfile, generateDockerIgnore, generateDotEnvLocal, generateFeltDBDockerfile, generateStudioDockerfile, } from './docker-compose-generator.js';
9
+ import { initializeWorkspace, appendWorkspaceGitignore, } from './workspace-initialization.js';
9
10
  export async function createProject(options) {
10
11
  const { projectName, templatesDir } = options;
11
12
  const projectDir = path.resolve(process.cwd(), projectName);
@@ -1759,4 +1760,24 @@ If port 5173 is in use:
1759
1760
  MIT
1760
1761
  `;
1761
1762
  fs.writeFileSync(path.join(projectDir, 'README.md'), readme);
1763
+ // Initialize Development Workspace
1764
+ // This creates .feltdb/workspace.json which enables all FeltDB-aware tools
1765
+ // (CLI, IDE, agents, browser extensions) to discover and connect to the
1766
+ // same workspace without manual configuration.
1767
+ const workspaceDiscovery = initializeWorkspace(projectDir, applicationName);
1768
+ appendWorkspaceGitignore(projectDir);
1769
+ // Copy .feltdb README from template
1770
+ const templateReadmePath = path.join(templatesDir, 'dot-feltdb-README.md');
1771
+ const targetReadmePath = path.join(projectDir, '.feltdb', 'README.md');
1772
+ if (fs.existsSync(templateReadmePath)) {
1773
+ fs.copyFileSync(templateReadmePath, targetReadmePath);
1774
+ }
1775
+ console.log(`\n✓ Development Workspace initialized`);
1776
+ console.log(` Workspace ID: ${workspaceDiscovery.workspaceId}`);
1777
+ console.log(` Location: ${path.join(projectName, '.feltdb/workspace.json')}`);
1778
+ console.log(`\nNext steps:`);
1779
+ console.log(` cd ${projectName}`);
1780
+ console.log(` npm install`);
1781
+ console.log(` npm run dev`);
1782
+ console.log(`\nYour Development Workspace is ready for use!`);
1762
1783
  }
@@ -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.6';
3
+ export const FELTDB_PACKAGE_VERSION = '0.6.0';
4
4
  export const feltdbPackageRange = `^${FELTDB_PACKAGE_VERSION}`;
@@ -513,6 +513,7 @@ dependencies = [
513
513
  "base64",
514
514
  "feltdb",
515
515
  "futures-core",
516
+ "hmac",
516
517
  "password-hash",
517
518
  "rand 0.8.7",
518
519
  "reqwest",
@@ -668,6 +669,15 @@ version = "0.5.2"
668
669
  source = "registry+https://github.com/rust-lang/crates.io-index"
669
670
  checksum = "fc0fef456e4baa96da950455cd02c081ca953b141298e41db3fc7e36b1da849c"
670
671
 
672
+ [[package]]
673
+ name = "hmac"
674
+ version = "0.12.1"
675
+ source = "registry+https://github.com/rust-lang/crates.io-index"
676
+ checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e"
677
+ dependencies = [
678
+ "digest",
679
+ ]
680
+
671
681
  [[package]]
672
682
  name = "http"
673
683
  version = "1.5.0"
@@ -9,6 +9,7 @@ async-stream = "0.3"
9
9
  argon2 = "0.5"
10
10
  password-hash = "0.5"
11
11
  base64 = "0.22"
12
+ hmac = "0.12"
12
13
  feltdb = { path = "../feltdb" }
13
14
  futures-core = "0.3"
14
15
  rand = "0.8"
@@ -1,5 +1,8 @@
1
- use std::sync::{atomic::AtomicU64, Arc};
2
1
  use std::path::PathBuf;
2
+ use std::{
3
+ sync::{atomic::AtomicU64, Arc},
4
+ time::Instant,
5
+ };
3
6
 
4
7
  use feltdb::{
5
8
  FeltDb,
@@ -33,6 +36,7 @@ use crate::{
33
36
 
34
37
  #[derive(Clone)]
35
38
  pub struct AppState {
39
+ pub started_at: Instant,
36
40
  pub db: FeltDb,
37
41
  pub namespace: Arc<str>,
38
42
  pub ids: Arc<AtomicU64>,
@@ -0,0 +1,273 @@
1
+ use serde::{Deserialize, Serialize};
2
+ use std::time::{SystemTime, UNIX_EPOCH};
3
+
4
+ /// Authenticated principal derived exclusively from verified credentials.
5
+ /// Never derived from request body or untrusted sources.
6
+ /// Available to authorization evaluation and included in request context.
7
+ #[derive(Debug, Clone, Serialize, Deserialize)]
8
+ pub struct AuthenticatedPrincipal {
9
+ /// ID of the service key used for authentication.
10
+ /// Preserved separately from actorId for auditing.
11
+ pub service_key_id: String,
12
+
13
+ /// ID of the actor (user, service, workload, etc.) performing the operation.
14
+ pub actor_id: String,
15
+
16
+ /// Tenant ID from authenticated credentials.
17
+ pub tenant_id: String,
18
+
19
+ /// Roles granted to this principal.
20
+ pub roles: Vec<String>,
21
+
22
+ /// Authentication method used.
23
+ pub auth_method: AuthMethod,
24
+
25
+ /// When this principal was authenticated.
26
+ pub authenticated_at: u64,
27
+
28
+ /// Provenance tracking for audit.
29
+ pub provenance: AuthenticationProvenance,
30
+ }
31
+
32
+ #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
33
+ #[serde(rename_all = "lowercase")]
34
+ pub enum AuthMethod {
35
+ ApiKey,
36
+ Session,
37
+ Delegation,
38
+ }
39
+
40
+ #[derive(Debug, Clone, Serialize, Deserialize)]
41
+ pub struct AuthenticationProvenance {
42
+ /// Source of the credential (e.g., "bearer_token", "session_cookie", "delegation_token").
43
+ pub credential_source: String,
44
+
45
+ /// Hash of the credential for audit purposes (never the credential itself).
46
+ pub credential_hash: Option<String>,
47
+
48
+ /// Additional context about the authentication.
49
+ pub context: String,
50
+ }
51
+
52
+ impl AuthenticatedPrincipal {
53
+ /// Creates a new AuthenticatedPrincipal from API key authentication.
54
+ pub fn from_api_key(
55
+ service_key_id: String,
56
+ actor_id: String,
57
+ tenant_id: String,
58
+ roles: Vec<String>,
59
+ ) -> Self {
60
+ Self {
61
+ service_key_id,
62
+ actor_id,
63
+ tenant_id,
64
+ roles,
65
+ auth_method: AuthMethod::ApiKey,
66
+ authenticated_at: now(),
67
+ provenance: AuthenticationProvenance {
68
+ credential_source: "bearer_token".into(),
69
+ credential_hash: None,
70
+ context: "API key authentication".into(),
71
+ },
72
+ }
73
+ }
74
+
75
+ /// Creates a new AuthenticatedPrincipal from session authentication.
76
+ pub fn from_session(
77
+ service_key_id: String,
78
+ actor_id: String,
79
+ tenant_id: String,
80
+ roles: Vec<String>,
81
+ ) -> Self {
82
+ Self {
83
+ service_key_id,
84
+ actor_id,
85
+ tenant_id,
86
+ roles,
87
+ auth_method: AuthMethod::Session,
88
+ authenticated_at: now(),
89
+ provenance: AuthenticationProvenance {
90
+ credential_source: "session_cookie".into(),
91
+ credential_hash: None,
92
+ context: "Session authentication".into(),
93
+ },
94
+ }
95
+ }
96
+
97
+ /// Creates a new AuthenticatedPrincipal from delegation token.
98
+ pub fn from_delegation(
99
+ service_key_id: String,
100
+ actor_id: String,
101
+ tenant_id: String,
102
+ roles: Vec<String>,
103
+ delegation_context: String,
104
+ ) -> Self {
105
+ Self {
106
+ service_key_id,
107
+ actor_id,
108
+ tenant_id,
109
+ roles,
110
+ auth_method: AuthMethod::Delegation,
111
+ authenticated_at: now(),
112
+ provenance: AuthenticationProvenance {
113
+ credential_source: "delegation_token".into(),
114
+ credential_hash: None,
115
+ context: delegation_context,
116
+ },
117
+ }
118
+ }
119
+
120
+ /// Checks if this principal has a specific role.
121
+ pub fn has_role(&self, role: &str) -> bool {
122
+ self.roles.iter().any(|r| r == role || r == "*")
123
+ }
124
+
125
+ /// Checks if actor_id matches expected value, rejecting mismatches.
126
+ pub fn verify_actor_id(&self, expected: &str) -> Result<(), String> {
127
+ if self.actor_id != expected {
128
+ Err(format!(
129
+ "actor_id mismatch: authenticated as {} but operation requested {}",
130
+ self.actor_id, expected
131
+ ))
132
+ } else {
133
+ Ok(())
134
+ }
135
+ }
136
+
137
+ /// Checks if tenant_id matches expected value, rejecting mismatches.
138
+ pub fn verify_tenant_id(&self, expected: &str) -> Result<(), String> {
139
+ if self.tenant_id != expected {
140
+ Err(format!(
141
+ "tenant_id mismatch: authenticated for {} but operation requested {}",
142
+ self.tenant_id, expected
143
+ ))
144
+ } else {
145
+ Ok(())
146
+ }
147
+ }
148
+ }
149
+
150
+ fn now() -> u64 {
151
+ SystemTime::now()
152
+ .duration_since(UNIX_EPOCH)
153
+ .unwrap_or_default()
154
+ .as_secs()
155
+ }
156
+
157
+
158
+ #[cfg(test)]
159
+ mod tests {
160
+ use super::*;
161
+
162
+ #[test]
163
+ fn authenticated_principal_preserves_separation() {
164
+ let principal = AuthenticatedPrincipal::from_api_key(
165
+ "key_abc123".into(),
166
+ "user_xyz".into(),
167
+ "tenant_prod".into(),
168
+ vec!["read".into(), "write".into()],
169
+ );
170
+
171
+ assert_eq!(principal.service_key_id, "key_abc123");
172
+ assert_eq!(principal.actor_id, "user_xyz");
173
+ assert_eq!(principal.tenant_id, "tenant_prod");
174
+ assert_eq!(principal.auth_method, AuthMethod::ApiKey);
175
+ }
176
+
177
+ #[test]
178
+ fn verify_actor_id_rejects_mismatches() {
179
+ let principal = AuthenticatedPrincipal::from_api_key(
180
+ "key_abc123".into(),
181
+ "user_xyz".into(),
182
+ "tenant_prod".into(),
183
+ vec![],
184
+ );
185
+
186
+ assert!(principal.verify_actor_id("user_xyz").is_ok());
187
+ assert!(principal.verify_actor_id("user_other").is_err());
188
+ }
189
+
190
+ #[test]
191
+ fn verify_tenant_id_rejects_mismatches() {
192
+ let principal = AuthenticatedPrincipal::from_api_key(
193
+ "key_abc123".into(),
194
+ "user_xyz".into(),
195
+ "tenant_prod".into(),
196
+ vec![],
197
+ );
198
+
199
+ assert!(principal.verify_tenant_id("tenant_prod").is_ok());
200
+ assert!(principal.verify_tenant_id("tenant_other").is_err());
201
+ }
202
+
203
+ #[test]
204
+ fn has_role_works_correctly() {
205
+ let principal = AuthenticatedPrincipal::from_api_key(
206
+ "key_abc123".into(),
207
+ "user_xyz".into(),
208
+ "tenant_prod".into(),
209
+ vec!["admin".into(), "viewer".into()],
210
+ );
211
+
212
+ assert!(principal.has_role("admin"));
213
+ assert!(principal.has_role("viewer"));
214
+ assert!(!principal.has_role("editor"));
215
+ }
216
+
217
+ #[test]
218
+ fn wildcard_role_matches_all() {
219
+ let principal = AuthenticatedPrincipal::from_api_key(
220
+ "key_abc123".into(),
221
+ "user_xyz".into(),
222
+ "tenant_prod".into(),
223
+ vec!["*".into()],
224
+ );
225
+
226
+ assert!(principal.has_role("any_role"));
227
+ assert!(principal.has_role("admin"));
228
+ }
229
+
230
+ #[test]
231
+ fn from_session_creates_correct_principal() {
232
+ let principal = AuthenticatedPrincipal::from_session(
233
+ "key_session".into(),
234
+ "user_abc".into(),
235
+ "tenant_staging".into(),
236
+ vec!["viewer".into()],
237
+ );
238
+
239
+ assert_eq!(principal.auth_method, AuthMethod::Session);
240
+ assert_eq!(principal.provenance.credential_source, "session_cookie");
241
+ }
242
+
243
+ #[test]
244
+ fn from_delegation_creates_correct_principal() {
245
+ let principal = AuthenticatedPrincipal::from_delegation(
246
+ "key_delegated".into(),
247
+ "service_xyz".into(),
248
+ "tenant_prod".into(),
249
+ vec!["execute".into()],
250
+ "delegated from sherpa".into(),
251
+ );
252
+
253
+ assert_eq!(principal.auth_method, AuthMethod::Delegation);
254
+ assert_eq!(principal.provenance.credential_source, "delegation_token");
255
+ assert_eq!(principal.provenance.context, "delegated from sherpa");
256
+ }
257
+
258
+ #[test]
259
+ fn multiple_calls_to_verify_preserve_state() {
260
+ let principal = AuthenticatedPrincipal::from_api_key(
261
+ "key_abc123".into(),
262
+ "user_xyz".into(),
263
+ "tenant_prod".into(),
264
+ vec![],
265
+ );
266
+
267
+ // Verify multiple times to ensure no state mutation
268
+ assert!(principal.verify_actor_id("user_xyz").is_ok());
269
+ assert!(principal.verify_actor_id("user_xyz").is_ok());
270
+ assert!(principal.verify_tenant_id("tenant_prod").is_ok());
271
+ assert!(principal.verify_tenant_id("tenant_prod").is_ok());
272
+ }
273
+ }