create-feltdb 0.8.0 → 0.8.2
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.
- package/dist/managed-account.js +1 -0
- package/dist/package-versions.js +1 -1
- package/dist/server-source/crates/feltdb/src/application.rs +23 -0
- package/dist/server-source/crates/feltdb/src/authority.rs +349 -0
- package/dist/server-source/crates/feltdb/src/bin/feltdb-authority-client.rs +32 -0
- package/dist/server-source/crates/feltdb/src/bin/feltdb-authority.rs +19 -0
- package/dist/server-source/crates/feltdb/src/lib.rs +444 -189
- package/dist/server-source/crates/feltdb/src/state_contract.rs +244 -51
- package/dist/server-source/crates/feltdb/tests/authority_process.rs +297 -0
- package/dist/server-source/crates/feltdb-server/src/auth.rs +41 -11
- package/dist/server-source/crates/feltdb-server/src/connections.rs +14 -0
- package/dist/server-source/crates/feltdb-server/src/key_management.rs +8 -3
- package/dist/server-source/crates/feltdb-server/src/main.rs +1262 -173
- package/dist/server-source/crates/feltdb-server/src/tenancy.rs +142 -1
- package/dist/template/default-project/src/context/AuthContext.tsx +9 -1
- package/dist/template/default-project/src/pages/SignUp.tsx +1 -1
- package/package.json +1 -1
|
@@ -41,6 +41,41 @@ pub struct Application {
|
|
|
41
41
|
pub active_revision: Option<String>,
|
|
42
42
|
#[serde(default)]
|
|
43
43
|
pub environment_revisions: std::collections::BTreeMap<String, String>,
|
|
44
|
+
#[serde(default)]
|
|
45
|
+
pub qualification: Option<QualificationMarker>,
|
|
46
|
+
}
|
|
47
|
+
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
|
48
|
+
pub struct QualificationMarker {
|
|
49
|
+
pub purpose: String,
|
|
50
|
+
pub disposable: bool,
|
|
51
|
+
pub environment: String,
|
|
52
|
+
pub proof_run_id: String,
|
|
53
|
+
pub expires_at: u64,
|
|
54
|
+
}
|
|
55
|
+
impl QualificationMarker {
|
|
56
|
+
fn valid_for_creation(&self, timestamp: u64) -> bool {
|
|
57
|
+
self.purpose == "qualification"
|
|
58
|
+
&& self.disposable
|
|
59
|
+
&& self.environment == "qualification"
|
|
60
|
+
&& !self.proof_run_id.trim().is_empty()
|
|
61
|
+
&& self.expires_at > timestamp
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
fn authorize_destruction(&self, proof_run_id: &str, timestamp: u64) -> Result<(), String> {
|
|
65
|
+
if self.purpose != "qualification" || !self.disposable {
|
|
66
|
+
return Err("refusing destructive qualification: application is not marked disposable".into());
|
|
67
|
+
}
|
|
68
|
+
if self.environment != "qualification" {
|
|
69
|
+
return Err("refusing destructive qualification: environment is not qualification".into());
|
|
70
|
+
}
|
|
71
|
+
if self.proof_run_id.trim().is_empty() || proof_run_id.trim().is_empty() || self.proof_run_id != proof_run_id {
|
|
72
|
+
return Err("refusing destructive qualification: proof run does not match".into());
|
|
73
|
+
}
|
|
74
|
+
if self.expires_at <= timestamp {
|
|
75
|
+
return Err("refusing destructive qualification: disposable marker has expired".into());
|
|
76
|
+
}
|
|
77
|
+
Ok(())
|
|
78
|
+
}
|
|
44
79
|
}
|
|
45
80
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
46
81
|
pub struct TenantMembership {
|
|
@@ -609,7 +644,7 @@ impl TenancyStore {
|
|
|
609
644
|
.find(|app| app.id == application_id)?
|
|
610
645
|
.clone();
|
|
611
646
|
drop(records);
|
|
612
|
-
self.authorize_application(user_id, &app.tenant_id, &app.id, "
|
|
647
|
+
self.authorize_application(user_id, &app.tenant_id, &app.id, "application:read")
|
|
613
648
|
.then_some(app)
|
|
614
649
|
}
|
|
615
650
|
pub fn application_tenant(&self, application_id: &str) -> Option<String> {
|
|
@@ -765,10 +800,25 @@ impl TenancyStore {
|
|
|
765
800
|
actor: &str,
|
|
766
801
|
tenant_id: &str,
|
|
767
802
|
name: &str,
|
|
803
|
+
) -> Result<Application, String> {
|
|
804
|
+
self.create_application_with_qualification(actor, tenant_id, name, None)
|
|
805
|
+
}
|
|
806
|
+
|
|
807
|
+
pub fn create_application_with_qualification(
|
|
808
|
+
&self,
|
|
809
|
+
actor: &str,
|
|
810
|
+
tenant_id: &str,
|
|
811
|
+
name: &str,
|
|
812
|
+
qualification: Option<QualificationMarker>,
|
|
768
813
|
) -> Result<Application, String> {
|
|
769
814
|
if !self.authorize_tenant(actor, tenant_id, "applications:write") {
|
|
770
815
|
return Err("forbidden".into());
|
|
771
816
|
}
|
|
817
|
+
if let Some(marker) = qualification.as_ref() {
|
|
818
|
+
if !marker.valid_for_creation(now()) {
|
|
819
|
+
return Err("invalid disposable qualification marker".into());
|
|
820
|
+
}
|
|
821
|
+
}
|
|
772
822
|
let mut records = self
|
|
773
823
|
.records
|
|
774
824
|
.write()
|
|
@@ -794,6 +844,7 @@ impl TenancyStore {
|
|
|
794
844
|
status: application_status(),
|
|
795
845
|
active_revision: None,
|
|
796
846
|
environment_revisions: Default::default(),
|
|
847
|
+
qualification,
|
|
797
848
|
};
|
|
798
849
|
records.application_memberships.push(ApplicationMembership {
|
|
799
850
|
application_id: application.id.clone(),
|
|
@@ -816,6 +867,22 @@ impl TenancyStore {
|
|
|
816
867
|
Ok(application)
|
|
817
868
|
}
|
|
818
869
|
|
|
870
|
+
pub fn delete_qualification_application(
|
|
871
|
+
&self,
|
|
872
|
+
actor: &str,
|
|
873
|
+
application_id: &str,
|
|
874
|
+
proof_run_id: &str,
|
|
875
|
+
) -> Result<(), String> {
|
|
876
|
+
let application = self
|
|
877
|
+
.application_for(actor, application_id)
|
|
878
|
+
.ok_or_else(|| "application not found".to_string())?;
|
|
879
|
+
let marker = application.qualification.ok_or_else(|| {
|
|
880
|
+
"refusing destructive qualification: application is not marked disposable".to_string()
|
|
881
|
+
})?;
|
|
882
|
+
marker.authorize_destruction(proof_run_id, now())?;
|
|
883
|
+
self.delete_application(actor, application_id)
|
|
884
|
+
}
|
|
885
|
+
|
|
819
886
|
pub fn delete_application(&self, actor: &str, application_id: &str) -> Result<(), String> {
|
|
820
887
|
let tenant_id = self
|
|
821
888
|
.application_tenant(application_id)
|
|
@@ -1238,6 +1305,73 @@ mod tests {
|
|
|
1238
1305
|
assert!(!store.authorize_application("user_a", &tenant_a.id, &app_b.id, "state:read"));
|
|
1239
1306
|
}
|
|
1240
1307
|
|
|
1308
|
+
#[test]
|
|
1309
|
+
fn destructive_qualification_requires_an_unexpired_matching_marker() {
|
|
1310
|
+
let valid_marker = QualificationMarker {
|
|
1311
|
+
purpose: "qualification".into(), disposable: true, environment: "qualification".into(), proof_run_id: "proof-1".into(), expires_at: now() + 300,
|
|
1312
|
+
};
|
|
1313
|
+
assert!(valid_marker.authorize_destruction("", now()).unwrap_err().contains("does not match"));
|
|
1314
|
+
assert!(valid_marker.authorize_destruction("proof-other", now()).unwrap_err().contains("does not match"));
|
|
1315
|
+
let mut wrong_environment = valid_marker.clone();
|
|
1316
|
+
wrong_environment.environment = "production".into();
|
|
1317
|
+
assert!(wrong_environment.authorize_destruction("proof-1", now()).unwrap_err().contains("environment"));
|
|
1318
|
+
let mut not_disposable = valid_marker.clone();
|
|
1319
|
+
not_disposable.disposable = false;
|
|
1320
|
+
assert!(not_disposable.authorize_destruction("proof-1", now()).unwrap_err().contains("not marked disposable"));
|
|
1321
|
+
let mut expired = valid_marker.clone();
|
|
1322
|
+
expired.expires_at = now().saturating_sub(1);
|
|
1323
|
+
assert!(expired.authorize_destruction("proof-1", now()).unwrap_err().contains("expired"));
|
|
1324
|
+
assert!(valid_marker.authorize_destruction("proof-1", now()).is_ok());
|
|
1325
|
+
|
|
1326
|
+
let store = store("qualification-guard");
|
|
1327
|
+
let tenant = store.create_tenant("owner", "Qualification").unwrap();
|
|
1328
|
+
let normal = store
|
|
1329
|
+
.create_application("owner", &tenant.id, "Normal")
|
|
1330
|
+
.unwrap();
|
|
1331
|
+
assert!(store
|
|
1332
|
+
.delete_qualification_application("owner", &normal.id, "proof-1")
|
|
1333
|
+
.unwrap_err()
|
|
1334
|
+
.contains("not marked disposable"));
|
|
1335
|
+
assert!(store
|
|
1336
|
+
.create_application_with_qualification(
|
|
1337
|
+
"owner",
|
|
1338
|
+
&tenant.id,
|
|
1339
|
+
"Expired",
|
|
1340
|
+
Some(QualificationMarker {
|
|
1341
|
+
purpose: "qualification".into(),
|
|
1342
|
+
disposable: true,
|
|
1343
|
+
environment: "qualification".into(),
|
|
1344
|
+
proof_run_id: "proof-expired".into(),
|
|
1345
|
+
expires_at: now().saturating_sub(1),
|
|
1346
|
+
}),
|
|
1347
|
+
)
|
|
1348
|
+
.unwrap_err()
|
|
1349
|
+
.contains("invalid disposable"));
|
|
1350
|
+
|
|
1351
|
+
let proof = store
|
|
1352
|
+
.create_application_with_qualification(
|
|
1353
|
+
"owner",
|
|
1354
|
+
&tenant.id,
|
|
1355
|
+
"Proof",
|
|
1356
|
+
Some(QualificationMarker {
|
|
1357
|
+
purpose: "qualification".into(),
|
|
1358
|
+
disposable: true,
|
|
1359
|
+
environment: "qualification".into(),
|
|
1360
|
+
proof_run_id: "proof-1".into(),
|
|
1361
|
+
expires_at: now() + 300,
|
|
1362
|
+
}),
|
|
1363
|
+
)
|
|
1364
|
+
.unwrap();
|
|
1365
|
+
assert!(store
|
|
1366
|
+
.delete_qualification_application("owner", &proof.id, "proof-other")
|
|
1367
|
+
.unwrap_err()
|
|
1368
|
+
.contains("does not match"));
|
|
1369
|
+
store
|
|
1370
|
+
.delete_qualification_application("owner", &proof.id, "proof-1")
|
|
1371
|
+
.unwrap();
|
|
1372
|
+
assert!(store.application_for("owner", &proof.id).is_none());
|
|
1373
|
+
}
|
|
1374
|
+
|
|
1241
1375
|
#[test]
|
|
1242
1376
|
fn invitation_acceptance_and_revocation_change_authorization() {
|
|
1243
1377
|
let store = store("invitation");
|
|
@@ -1322,7 +1456,14 @@ mod tests {
|
|
|
1322
1456
|
.unwrap();
|
|
1323
1457
|
|
|
1324
1458
|
assert!(store.authorize_application("key_runtime", &tenant.id, &app.id, "state:read"));
|
|
1459
|
+
assert_eq!(
|
|
1460
|
+
store
|
|
1461
|
+
.application_for("key_runtime", &app.id)
|
|
1462
|
+
.map(|application| application.id),
|
|
1463
|
+
Some(app.id.clone())
|
|
1464
|
+
);
|
|
1325
1465
|
assert!(!store.authorize_application("key_runtime", &tenant.id, &app.id, "state:write"));
|
|
1466
|
+
assert!(store.application_for("key_runtime", &other.id).is_none());
|
|
1326
1467
|
assert!(!store.authorize_application("key_runtime", &tenant.id, &other.id, "state:read"));
|
|
1327
1468
|
}
|
|
1328
1469
|
}
|
|
@@ -17,7 +17,15 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
|
|
17
17
|
const loading = sessionRecords.loading || userRecords.loading || membershipRecords.loading || organizationRecords.loading;
|
|
18
18
|
const establish = (next: Session) => { sessionStorage.setItem(SESSION_KEY, next.id); setSessionId(next.id); };
|
|
19
19
|
const value = useMemo<AuthValue>(() => ({ loading, authenticated: Boolean(session && user && membership && organization), session, user, membership, organization,
|
|
20
|
-
signUp: async input => {
|
|
20
|
+
signUp: async input => {
|
|
21
|
+
try {
|
|
22
|
+
await db.auth.signUp({ email: input.email, password: input.password, display_name: input.name });
|
|
23
|
+
} catch (error) {
|
|
24
|
+
if ((error as { code?: string }).code !== 'CONFLICT') throw error;
|
|
25
|
+
await db.auth.signIn({ email: input.email, password: input.password });
|
|
26
|
+
}
|
|
27
|
+
establish(await createAccount(input));
|
|
28
|
+
},
|
|
21
29
|
signIn: async (email, password) => { await db.auth.signIn({ email, password }); establish(await createSession(email)); },
|
|
22
30
|
signOut: async () => { if (session) await sessions.delete(session.id); await db.auth.signOut(); sessionStorage.removeItem(SESSION_KEY); setSessionId(null); },
|
|
23
31
|
}), [loading, session, user, membership, organization]);
|
|
@@ -3,6 +3,6 @@ import { useAuth } from '../context/AuthContext';
|
|
|
3
3
|
|
|
4
4
|
export function SignUp() {
|
|
5
5
|
const { signUp } = useAuth(); const [error, setError] = useState(''); const [busy, setBusy] = useState(false);
|
|
6
|
-
async function submit(event: FormEvent<HTMLFormElement>) { event.preventDefault(); setBusy(true); setError(''); const form = new FormData(event.currentTarget); try { await signUp({ name: String(form.get('name')), email: String(form.get('email')), password: String(form.get('password')), organization: String(form.get('organization')) }); } catch { setError('We could not create your account. Check the details and try again.'); setBusy(false); } }
|
|
6
|
+
async function submit(event: FormEvent<HTMLFormElement>) { event.preventDefault(); setBusy(true); setError(''); const form = new FormData(event.currentTarget); try { await signUp({ name: String(form.get('name')), email: String(form.get('email')), password: String(form.get('password')), organization: String(form.get('organization')) }); } catch (error) { setError(error instanceof Error ? error.message : 'We could not create your account. Check the details and try again.'); setBusy(false); } }
|
|
7
7
|
return <><span className="eyebrow">Create your workspace</span><h2>Start with durable state.</h2><p className="muted">Your actor, account, and organization are established at the FeltDB boundary.</p><form onSubmit={submit}><label>Name<input name="name" autoComplete="name" required placeholder="Ada Lovelace" /></label><label>Work email<input name="email" type="email" autoComplete="email" required placeholder="ada@example.com" /></label><label>Password<input name="password" type="password" autoComplete="new-password" minLength={8} required /></label><label>Organization<input name="organization" required placeholder="Analytical Engines" /></label>{error && <p className="error">{error}</p>}<button disabled={busy}>{busy ? 'Creating…' : 'Create account'}</button></form><p className="switch">Already have an account? <a href="?view=signin">Sign in</a></p></>;
|
|
8
8
|
}
|