create-feltdb 0.5.7 → 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.
package/dist/cli.js CHANGED
@@ -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,
package/dist/create.js CHANGED
@@ -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.7';
3
+ export const FELTDB_PACKAGE_VERSION = '0.6.0';
4
4
  export const feltdbPackageRange = `^${FELTDB_PACKAGE_VERSION}`;
@@ -104,12 +104,18 @@ struct ApiError(StatusCode, String);
104
104
 
105
105
  impl IntoResponse for ApiError {
106
106
  fn into_response(self) -> Response {
107
- let body =
108
- serde_json::from_str::<Value>(&self.1).unwrap_or_else(|_| json!({ "error": self.1 }));
107
+ let body = api_error_body(self.0, self.1);
109
108
  (self.0, Json(body)).into_response()
110
109
  }
111
110
  }
112
111
 
112
+ fn api_error_body(status: StatusCode, message: String) -> Value {
113
+ if status == StatusCode::FORBIDDEN {
114
+ return json!({ "error": "AUTHORIZATION_DENIED" });
115
+ }
116
+ serde_json::from_str::<Value>(&message).unwrap_or_else(|_| json!({ "error": message }))
117
+ }
118
+
113
119
  impl From<feltdb::FlowError> for ApiError {
114
120
  fn from(error: feltdb::FlowError) -> Self {
115
121
  Self(StatusCode::INTERNAL_SERVER_ERROR, error.to_string())
@@ -2199,6 +2205,38 @@ async fn delete_application_control(
2199
2205
  .map_err(control_error)?;
2200
2206
  Ok(StatusCode::NO_CONTENT)
2201
2207
  }
2208
+
2209
+ async fn delete_certification_fixture(
2210
+ State(state): State<AppState>,
2211
+ Path(application_id): Path<String>,
2212
+ headers: HeaderMap,
2213
+ ) -> Result<Json<Value>, ApiError> {
2214
+ let expected = std::env::var("FELTDB_CERTIFICATION_RECOVERY_TOKEN").map_err(|_| {
2215
+ ApiError(
2216
+ StatusCode::SERVICE_UNAVAILABLE,
2217
+ "certification recovery is not configured".into(),
2218
+ )
2219
+ })?;
2220
+ let provided = headers
2221
+ .get("x-feltdb-certification-recovery")
2222
+ .and_then(|value| value.to_str().ok())
2223
+ .unwrap_or_default();
2224
+ if expected.len() < 32
2225
+ || ring::constant_time::verify_slices_are_equal(expected.as_bytes(), provided.as_bytes())
2226
+ .is_err()
2227
+ {
2228
+ return Err(ApiError(StatusCode::FORBIDDEN, "certification recovery denied".into()));
2229
+ }
2230
+ let tenant_id = state
2231
+ .tenancy
2232
+ .delete_certification_fixture(&application_id)
2233
+ .map_err(control_error)?;
2234
+ Ok(Json(json!({
2235
+ "deleted": true,
2236
+ "tenant_id": tenant_id,
2237
+ "application_id": application_id,
2238
+ })))
2239
+ }
2202
2240
  async fn update_application_control(
2203
2241
  State(state): State<AppState>,
2204
2242
  Extension(principal): Extension<Principal>,
@@ -3814,7 +3852,36 @@ async fn commit_application_draft(
3814
3852
  .applications
3815
3853
  .commit(&tenant, &application_id, &draft_id, &principal.key_id)
3816
3854
  .map_err(manifest_error)?;
3817
- Ok((StatusCode::CREATED, Json(json!(revision))))
3855
+ let encoded_revision = percent_encode_path_segment(&revision.revision_id);
3856
+ let mut response = serde_json::to_value(revision).map_err(|error| {
3857
+ ApiError(StatusCode::INTERNAL_SERVER_ERROR, error.to_string())
3858
+ })?;
3859
+ response
3860
+ .as_object_mut()
3861
+ .expect("application revisions serialize as objects")
3862
+ .insert(
3863
+ "promotion".into(),
3864
+ json!({
3865
+ "status": "ready",
3866
+ "url": format!(
3867
+ "/api/applications/{application_id}/revisions/{encoded_revision}/promote"
3868
+ ),
3869
+ }),
3870
+ );
3871
+ Ok((StatusCode::CREATED, Json(response)))
3872
+ }
3873
+
3874
+ fn percent_encode_path_segment(value: &str) -> String {
3875
+ value
3876
+ .bytes()
3877
+ .flat_map(|byte| {
3878
+ if byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_' | b'.' | b'~') {
3879
+ vec![byte as char]
3880
+ } else {
3881
+ format!("%{byte:02X}").chars().collect()
3882
+ }
3883
+ })
3884
+ .collect()
3818
3885
  }
3819
3886
  async fn list_application_revisions(
3820
3887
  State(state): State<AppState>,
@@ -5789,6 +5856,10 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
5789
5856
  axum::routing::patch(platform_update_application_member),
5790
5857
  )
5791
5858
  .route("/api/tenants/{tenant_id}", get(get_tenant))
5859
+ .route(
5860
+ "/api/certification/fixtures/{application_id}",
5861
+ axum::routing::delete(delete_certification_fixture),
5862
+ )
5792
5863
  .route("/api/keys", get(list_keys).post(create_key))
5793
5864
  .route("/api/keys/{id}", axum::routing::delete(revoke_key))
5794
5865
  .route(
@@ -6314,6 +6385,10 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
6314
6385
  .route("/ready", get(readiness))
6315
6386
  .route("/runtime", get(runtime))
6316
6387
  .route("/metrics", get(network_metrics))
6388
+ .route(
6389
+ "/api/certification/fixtures/{application_id}",
6390
+ axum::routing::delete(delete_certification_fixture),
6391
+ )
6317
6392
  .merge(protected)
6318
6393
  .layer(middleware::from_fn(protocol_version))
6319
6394
  .layer(middleware::from_fn_with_state(state.clone(), count_request))
@@ -9171,7 +9246,8 @@ async fn shutdown_signal() {
9171
9246
 
9172
9247
  #[cfg(test)]
9173
9248
  mod authority_gate_tests {
9174
- use super::{authorize_transaction_collections, state_authorization};
9249
+ use super::{api_error_body, authorize_transaction_collections, state_authorization};
9250
+ use axum::http::StatusCode;
9175
9251
  use feltdb::{
9176
9252
  application::{manifest_hash, ApplicationManifest, ApplicationRevision, PolicyDefinition, RevisionStatus},
9177
9253
  application_runtime::{resolve_runtime, ApplicationRuntimeContract, RuntimeInventory},
@@ -9256,4 +9332,19 @@ mod authority_gate_tests {
9256
9332
  );
9257
9333
  assert!(!allowed);
9258
9334
  }
9335
+
9336
+ #[test]
9337
+ fn every_forbidden_error_uses_the_canonical_wire_contract() {
9338
+ for internal_message in [
9339
+ "forbidden",
9340
+ "tenant access denied",
9341
+ "missing scope: state:read",
9342
+ r#"{"code":"AUTHORIZATION_DENIED","message":"policy denied"}"#,
9343
+ ] {
9344
+ assert_eq!(
9345
+ api_error_body(StatusCode::FORBIDDEN, internal_message.into()),
9346
+ serde_json::json!({ "error": "AUTHORIZATION_DENIED" })
9347
+ );
9348
+ }
9349
+ }
9259
9350
  }
@@ -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,
@@ -0,0 +1,58 @@
1
+ # FeltDB Development Workspace
2
+
3
+ This directory contains the development workspace configuration for your FeltDB project.
4
+
5
+ ## workspace.json
6
+
7
+ **Purpose:** Durable pairing identity for your project's development workspace.
8
+
9
+ **Contents:**
10
+ - `workspaceId` — Unique identifier for this workspace (format: `ws_*`)
11
+ - `projectId` — Your project name
12
+ - `version` — Configuration version
13
+
14
+ **Usage:**
15
+ - Committed to version control (this is your project's identity)
16
+ - Used by CLI, IDE, agents, and browser extensions to discover the workspace
17
+ - DO NOT edit manually unless you know what you're doing
18
+
19
+ **Discovery:**
20
+ When any FeltDB tool (VS Code, Claude agent, CLI, browser extension) opens your project, it reads this file to automatically connect to the correct workspace.
21
+
22
+ ## Runtime Files (not committed)
23
+
24
+ - `pairing.json` — Short-lived pairing token for browser discovery (gitignored)
25
+ - `state.json` — Local workspace state (gitignored)
26
+ - `*.log` — Development logs (gitignored)
27
+
28
+ ## Commands
29
+
30
+ ```bash
31
+ # Check workspace status
32
+ npm run feltdb:status
33
+
34
+ # Or use the CLI directly
35
+ feltdb workspace status
36
+
37
+ # Launch development environment (includes workspace authority)
38
+ npm run dev
39
+ ```
40
+
41
+ ## Development Workspace Architecture
42
+
43
+ ```
44
+ Your Project
45
+
46
+ .feltdb/workspace.json (pairing identity)
47
+
48
+ feltdb dev (workspace authority)
49
+
50
+ Browser, IDE, Agent clients
51
+ ```
52
+
53
+ All tools automatically discover your workspace using `workspace.json` and connect to the local development environment started by `npm run dev`.
54
+
55
+ ## Learn More
56
+
57
+ - [Development Workspaces Documentation](https://github.com/rkendel1/feltdb/blob/main/packages/core/docs/development-workspaces.md)
58
+ - [FeltDB Documentation](https://github.com/rkendel1/feltdb)
@@ -0,0 +1,77 @@
1
+ /**
2
+ * Workspace Initialization
3
+ *
4
+ * Initializes a FeltDB Development Workspace for newly created projects.
5
+ * This is the bootstrap mechanism that enables all FeltDB-aware tools
6
+ * to discover and connect to the same workspace.
7
+ */
8
+ import fs from 'fs';
9
+ import path from 'path';
10
+ /**
11
+ * Generate a unique workspace ID
12
+ * Format: ws_<projectId>_<timestamp>_<random>
13
+ */
14
+ export function generateWorkspaceId(projectId) {
15
+ const timestamp = Date.now();
16
+ const random = Math.random().toString(36).substring(2, 9);
17
+ return `ws_${projectId}_${timestamp}_${random}`;
18
+ }
19
+ /**
20
+ * Initialize development workspace for a new project
21
+ *
22
+ * Creates .feltdb/workspace.json with the workspace discovery information.
23
+ * This file is committed to the repository and used by all development tools
24
+ * (CLI, IDE, agents, browser extensions) to discover and connect to the
25
+ * same workspace.
26
+ */
27
+ export function initializeWorkspace(projectDir, projectId) {
28
+ const feltdbDir = path.join(projectDir, '.feltdb');
29
+ // Ensure .feltdb directory exists
30
+ if (!fs.existsSync(feltdbDir)) {
31
+ fs.mkdirSync(feltdbDir, { recursive: true });
32
+ }
33
+ // Create workspace discovery
34
+ const workspaceId = generateWorkspaceId(projectId);
35
+ const discovery = {
36
+ workspaceId,
37
+ projectId,
38
+ version: 1,
39
+ };
40
+ // Write workspace.json
41
+ const workspacePath = path.join(feltdbDir, 'workspace.json');
42
+ fs.writeFileSync(workspacePath, JSON.stringify(discovery, null, 2) + '\n');
43
+ return discovery;
44
+ }
45
+ /**
46
+ * Create .gitignore entry for workspace runtime files
47
+ *
48
+ * The .feltdb/workspace.json is committed (pairing identity).
49
+ * But runtime files like pairing tokens should not be committed.
50
+ */
51
+ export function appendWorkspaceGitignore(projectDir) {
52
+ const gitignorePath = path.join(projectDir, '.gitignore');
53
+ const workspaceGitignoreEntries = [
54
+ '',
55
+ '# FeltDB Development Workspace',
56
+ '.feltdb/pairing.json',
57
+ '.feltdb/state.json',
58
+ '.feltdb/*.log',
59
+ '',
60
+ ].join('\n');
61
+ if (fs.existsSync(gitignorePath)) {
62
+ const existing = fs.readFileSync(gitignorePath, 'utf-8');
63
+ if (!existing.includes('.feltdb/pairing.json')) {
64
+ fs.appendFileSync(gitignorePath, workspaceGitignoreEntries);
65
+ }
66
+ }
67
+ else {
68
+ const content = [
69
+ 'node_modules/',
70
+ 'dist/',
71
+ 'build/',
72
+ '.env.local',
73
+ workspaceGitignoreEntries,
74
+ ].join('\n');
75
+ fs.writeFileSync(gitignorePath, content);
76
+ }
77
+ }
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.5.7",
5
+ "version": "0.6.0",
6
6
  "license": "MIT",
7
7
  "bin": {
8
8
  "create-feltdb": "bin/create-feltdb.js"