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.
@@ -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, "applications:read")
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 => { await db.auth.signUp({ email: input.email, password: input.password, display_name: input.name }); establish(await createAccount(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
  }
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.8.0",
5
+ "version": "0.8.2",
6
6
  "license": "MIT",
7
7
  "bin": {
8
8
  "create-feltdb": "bin/create-feltdb.js"