@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
@@ -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, initializeWorkspace } 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], {
@@ -383,11 +431,50 @@ async function handleDev(args) {
383
431
  var _a, _b, _c;
384
432
  loadProjectEnvironment();
385
433
  console.log('🚀 Starting FeltDB development server...\n');
386
- // Check for feltdb.config.json
387
- const configPath = path.join(process.cwd(), 'feltdb.config.json');
434
+ const projectDir = process.cwd();
435
+ const configPath = path.join(projectDir, 'feltdb.config.json');
436
+ // Initialize workspace if needed
437
+ let workspace = discoverWorkspace(projectDir);
438
+ if (!workspace) {
439
+ // Get project name from package.json
440
+ const packageJsonPath = path.join(projectDir, 'package.json');
441
+ let projectId = path.basename(projectDir);
442
+ if (fs.existsSync(packageJsonPath)) {
443
+ try {
444
+ const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, 'utf-8'));
445
+ projectId = packageJson.name || projectId;
446
+ }
447
+ catch {
448
+ // Use default projectId if package.json parsing fails
449
+ }
450
+ }
451
+ workspace = initializeWorkspace(projectDir, projectId);
452
+ console.log(`✨ Initialized Development Workspace`);
453
+ console.log(` Workspace ID: ${workspace.workspaceId}\n`);
454
+ }
455
+ // Create feltdb.config.json if it doesn't exist
388
456
  if (!fs.existsSync(configPath)) {
389
- console.error('❌ feltdb.config.json not found');
390
- process.exit(1);
457
+ const packageJsonPath = path.join(projectDir, 'package.json');
458
+ let namespace = path.basename(projectDir);
459
+ if (fs.existsSync(packageJsonPath)) {
460
+ try {
461
+ const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, 'utf-8'));
462
+ namespace = packageJson.name || namespace;
463
+ }
464
+ catch {
465
+ // Use default namespace if package.json parsing fails
466
+ }
467
+ }
468
+ const defaultConfig = {
469
+ namespace,
470
+ runtime: 'browser',
471
+ storage: 'indexeddb',
472
+ distributed: true,
473
+ agents: { enabled: false },
474
+ capabilities: { search: true },
475
+ };
476
+ fs.writeFileSync(configPath, JSON.stringify(defaultConfig, null, 2) + '\n');
477
+ console.log(`✨ Created feltdb.config.json with default configuration\n`);
391
478
  }
392
479
  const config = JSON.parse(fs.readFileSync(configPath, 'utf-8'));
393
480
  if (config.runtime === 'managed') {
@@ -432,11 +519,29 @@ async function handleDev(args) {
432
519
  selfHostedStarted = true;
433
520
  (_c = process.env).VITE_FELTDB_URL || (_c.VITE_FELTDB_URL = 'http://127.0.0.1:7700');
434
521
  }
522
+ // Generate pairing token for the workspace
523
+ // This enables browsers and IDEs to discover and connect to the workspace
524
+ let pairingToken = null;
525
+ if (workspace) {
526
+ const token = generatePairingToken();
527
+ token.workspaceId = workspace.workspaceId;
528
+ persistPairingToken(process.cwd(), token);
529
+ pairingToken = token.token;
530
+ }
435
531
  console.log('FeltDB Dev Server');
436
532
  console.log(` Namespace: ${config.namespace}`);
437
533
  console.log(` Runtime: ${config.runtime}`);
438
534
  console.log(` Storage: ${config.storage}`);
439
- console.log(` Distributed: ${config.distributed}\n`);
535
+ console.log(` Distributed: ${config.distributed}`);
536
+ if (workspace) {
537
+ console.log(` Workspace: ${workspace.workspaceId}\n`);
538
+ console.log(`🔗 Development Workspace`);
539
+ if (pairingToken) {
540
+ console.log(` Pairing Code: ${pairingToken}`);
541
+ console.log(` Clients can use this code to auto-discover the workspace`);
542
+ }
543
+ }
544
+ console.log();
440
545
  const open = !args.includes('--no-open');
441
546
  console.log(`Application: http://127.0.0.1:${appPort}`);
442
547
  console.log(`Studio: http://127.0.0.1:${studioPort}\n`);
package/dist/cli/index.js CHANGED
@@ -23,7 +23,7 @@ import * as path from 'path';
23
23
  import * as readline from 'readline';
24
24
  import { getClient } from './api-client.js';
25
25
  import { loadFeltDBConfig, createDefaultConfig, validateModel, } from './config.js';
26
- const VERSION = '0.5.3';
26
+ const VERSION = '0.6.1';
27
27
  function prompt(question) {
28
28
  const rl = readline.createInterface({
29
29
  input: process.stdin,
@@ -0,0 +1,127 @@
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
+ * Generate a unique workspace ID
78
+ * Format: ws_<projectId>_<timestamp>_<random>
79
+ */
80
+ export function generateWorkspaceId(projectId) {
81
+ const timestamp = Date.now();
82
+ const random = Math.random().toString(36).substring(2, 9);
83
+ return `ws_${projectId}_${timestamp}_${random}`;
84
+ }
85
+ /**
86
+ * Initialize development workspace for a project
87
+ * Creates .feltdb/workspace.json with workspace discovery information.
88
+ */
89
+ export function initializeWorkspace(projectDir, projectId) {
90
+ const feltdbDir = path.join(projectDir, '.feltdb');
91
+ // Ensure .feltdb directory exists
92
+ if (!fs.existsSync(feltdbDir)) {
93
+ fs.mkdirSync(feltdbDir, { recursive: true });
94
+ }
95
+ // Create workspace discovery
96
+ const workspaceId = generateWorkspaceId(projectId);
97
+ const discovery = {
98
+ workspaceId,
99
+ projectId,
100
+ version: 1,
101
+ };
102
+ // Write workspace.json
103
+ const workspacePath = path.join(feltdbDir, 'workspace.json');
104
+ fs.writeFileSync(workspacePath, JSON.stringify(discovery, null, 2) + '\n');
105
+ return discovery;
106
+ }
107
+ /**
108
+ * Display workspace status information
109
+ */
110
+ export function displayWorkspaceStatus(projectDir) {
111
+ const workspace = discoverWorkspace(projectDir);
112
+ if (!workspace) {
113
+ console.log('❌ No Development Workspace found');
114
+ console.log(' Run "feltdb dev" to initialize the workspace');
115
+ return;
116
+ }
117
+ const pairingToken = readPairingToken(projectDir);
118
+ console.log('\n🔗 FeltDB Development Workspace\n');
119
+ console.log(` Project: ${workspace.projectId}`);
120
+ console.log(` Workspace: ${workspace.workspaceId}`);
121
+ if (pairingToken) {
122
+ console.log(` Pairing: ${pairingToken.token}`);
123
+ const minutesRemaining = Math.ceil((pairingToken.expiresAt - Date.now()) / 60000);
124
+ console.log(` Expires in: ${minutesRemaining} minutes`);
125
+ }
126
+ console.log('\n Location: .feltdb/workspace.json\n');
127
+ }
@@ -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.1';
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
+ }