create-feltdb 0.7.3 → 0.8.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 (52) hide show
  1. package/dist/cli.js +11 -2
  2. package/dist/create.js +38 -16
  3. package/dist/managed-account.js +82 -11
  4. package/dist/package-versions.js +1 -1
  5. package/dist/server-source/crates/feltdb/src/application.rs +41 -1
  6. package/dist/server-source/crates/feltdb/src/authority_failover.rs +84 -6
  7. package/dist/server-source/crates/feltdb/src/bin/feltdb_node.rs +126 -38
  8. package/dist/server-source/crates/feltdb/src/distributed_transactions.rs +72 -34
  9. package/dist/server-source/crates/feltdb/src/lib.rs +20 -2
  10. package/dist/server-source/crates/feltdb-server/src/app_state.rs +12 -24
  11. package/dist/server-source/crates/feltdb-server/src/application_contract.rs +11 -3
  12. package/dist/server-source/crates/feltdb-server/src/authenticated_principal.rs +0 -1
  13. package/dist/server-source/crates/feltdb-server/src/certification_harness.rs +23 -22
  14. package/dist/server-source/crates/feltdb-server/src/delegation_token.rs +5 -15
  15. package/dist/server-source/crates/feltdb-server/src/durable_operations.rs +13 -28
  16. package/dist/server-source/crates/feltdb-server/src/identity.rs +111 -3
  17. package/dist/server-source/crates/feltdb-server/src/key_management.rs +28 -7
  18. package/dist/server-source/crates/feltdb-server/src/lib.rs +5 -5
  19. package/dist/server-source/crates/feltdb-server/src/main.rs +1960 -143
  20. package/dist/server-source/crates/feltdb-server/src/managed_diagnostics.rs +10 -30
  21. package/dist/server-source/crates/feltdb-server/src/membership_policy.rs +12 -4
  22. package/dist/server-source/crates/feltdb-server/src/request_telemetry.rs +8 -6
  23. package/dist/server-source/crates/feltdb-server/src/snapshot_cursor.rs +10 -14
  24. package/dist/server-source/crates/feltdb-server/src/tenancy.rs +195 -13
  25. package/dist/server-source/crates/feltdb-server/src/tenant_policies.rs +9 -21
  26. package/dist/server-source/crates/feltdb-server/src/transaction_idempotency.rs +5 -3
  27. package/dist/server-source/crates/feltdb-server/src/transaction_recovery.rs +9 -8
  28. package/dist/server-source/crates/feltdb-server/tests/revision_recovery_integration_test.rs +1 -4
  29. package/dist/template/default-project/README.md +57 -0
  30. package/dist/template/default-project/agents/activity-assistant.ts +18 -0
  31. package/dist/template/default-project/agents/project-assistant.ts +19 -0
  32. package/dist/template/default-project/capabilities/activity-summary.ts +14 -0
  33. package/dist/template/default-project/capabilities/project-search.ts +16 -0
  34. package/dist/template/default-project/capabilities/project-summary.ts +15 -0
  35. package/dist/template/default-project/feltdb.flow +174 -0
  36. package/dist/template/default-project/src/App.tsx +20 -0
  37. package/dist/template/default-project/src/context/AuthContext.tsx +27 -0
  38. package/dist/template/default-project/src/feltdb.ts +87 -0
  39. package/dist/template/default-project/src/index.tsx +14 -0
  40. package/dist/template/default-project/src/pages/Activity.tsx +7 -0
  41. package/dist/template/default-project/src/pages/Agents.tsx +15 -0
  42. package/dist/template/default-project/src/pages/Dashboard.tsx +17 -0
  43. package/dist/template/default-project/src/pages/Invitations.tsx +11 -0
  44. package/dist/template/default-project/src/pages/Projects.tsx +11 -0
  45. package/dist/template/default-project/src/pages/SignIn.tsx +8 -0
  46. package/dist/template/default-project/src/pages/SignUp.tsx +8 -0
  47. package/dist/template/default-project/src/styles-application.css +21 -0
  48. package/dist/template/default-project/src/styles.css +1 -0
  49. package/dist/template/default-project/workflows/agent-assisted-summary.ts +1 -0
  50. package/dist/template/default-project/workflows/invitation.ts +30 -0
  51. package/dist/template/default-project/workflows/project-created.ts +2 -0
  52. package/package.json +1 -1
package/dist/cli.js CHANGED
@@ -10,7 +10,7 @@ import readline from 'readline';
10
10
  import { spawn, spawnSync } from 'child_process';
11
11
  import { createProject } from './create.js';
12
12
  import { FELTDB_PACKAGE_VERSION } from './package-versions.js';
13
- import { configureManagedAccount } from './managed-account.js';
13
+ import { configureManagedAccount, finalizeManagedProvisioning } from './managed-account.js';
14
14
  import { localFeltdbExecutable } from './development-handoff.js';
15
15
  const __dirname = path.dirname(fileURLToPath(import.meta.url));
16
16
  function parseArgs(args) {
@@ -308,7 +308,7 @@ Learn more: https://github.com/rkendel1/feltdb`);
308
308
  await createProject({
309
309
  projectName,
310
310
  autoYes: shouldAutoYes,
311
- templatesDir: path.join(__dirname, '../template'),
311
+ templatesDir: path.join(__dirname, 'template'),
312
312
  runtime: options.runtime,
313
313
  framework: options.framework,
314
314
  distributed: options.distributed,
@@ -331,9 +331,18 @@ Learn more: https://github.com/rkendel1/feltdb`);
331
331
  }
332
332
  console.log('\nāœ… FeltDB application created successfully!\n');
333
333
  const npm = process.platform === 'win32' ? 'npm.cmd' : 'npm';
334
+ if (options.runtime === 'managed' && !shouldInstall) {
335
+ throw new Error('Managed provisioning requires dependency installation so the initial revision can be built, published, promoted, and verified. Remove --no-install.');
336
+ }
334
337
  if (shouldInstall) {
335
338
  console.log(`šŸ“¦ Installing application, Studio${options.webllm ? ', and local WebLLM app builder' : ''} dependencies...\n`);
336
339
  await run(npm, ['install'], projectDir);
340
+ if (options.runtime === 'managed') {
341
+ console.log('\nā˜ļø Publishing and verifying the initial managed revision...\n');
342
+ await run(localFeltdbExecutable(projectDir), ['publish', '--yes', '--approve-destructive-changes'], projectDir);
343
+ const finalized = await finalizeManagedProvisioning({ projectDir });
344
+ console.log(`āœ“ Managed runtime verified at revision ${finalized.revisionId}`);
345
+ }
337
346
  }
338
347
  if (shouldStart) {
339
348
  if (!shouldInstall) {
package/dist/create.js CHANGED
@@ -49,9 +49,12 @@ export async function createProject(options) {
49
49
  test: 'node --test',
50
50
  feltdb: 'feltdb',
51
51
  'feltdb:server': 'feltdb server',
52
- 'feltdb:connect': 'feltdb connect http://localhost:7700',
52
+ 'feltdb:connect': 'feltdb connect',
53
53
  'feltdb:status': 'feltdb status',
54
54
  'feltdb:studio': 'feltdb studio',
55
+ 'feltdb:publish': 'feltdb publish',
56
+ 'feltdb:ai': 'feltdb ai',
57
+ 'feltdb:sync': 'feltdb sync',
55
58
  'feltdb:validate': 'feltdb validate',
56
59
  'feltdb:diff': 'feltdb diff',
57
60
  'feltdb:deploy': 'feltdb deploy',
@@ -176,25 +179,26 @@ ${hasAgents ? ` agent WorkspaceAssistant {
176
179
  lib: ['ES2020', 'DOM'],
177
180
  declaration: true,
178
181
  outDir: './dist',
179
- rootDir: './src',
182
+ rootDir: '.',
180
183
  strict: true,
181
184
  esModuleInterop: true,
182
185
  skipLibCheck: true,
183
186
  forceConsistentCasingInFileNames: true,
184
- moduleResolution: 'node',
187
+ moduleResolution: 'bundler',
185
188
  },
186
- include: ['src/**/*'],
189
+ include: framework === 'react' ? ['src/**/*', 'agents/**/*', 'capabilities/**/*', 'workflows/**/*'] : ['src/**/*'],
187
190
  exclude: ['node_modules'],
188
191
  };
189
192
  if (framework === 'react') {
190
193
  tsconfig.compilerOptions.jsx = 'react-jsx';
194
+ tsconfig.compilerOptions.types = ['vite/client'];
191
195
  }
192
196
  fs.writeFileSync(path.join(projectDir, 'tsconfig.json'), JSON.stringify(tsconfig, null, 2));
193
197
  // Create main application files
194
198
  const runtimeOptions = runtime === 'browser'
195
199
  ? "{ namespace: import.meta.env.VITE_FELTDB_NAMESPACE || '" + applicationName + "', browser: true }"
196
200
  : runtime === 'managed'
197
- ? "{ namespace: import.meta.env.VITE_FELTDB_MANAGED_NAMESPACE || import.meta.env.VITE_FELTDB_NAMESPACE || '" + applicationName + "', server: { url: import.meta.env.VITE_FELTDB_MANAGED_URL || import.meta.env.VITE_FELTDB_URL || '', token: import.meta.env.VITE_FELTDB_MANAGED_API_KEY || import.meta.env.VITE_FELTDB_API_KEY || '' } }"
201
+ ? "{ namespace: '" + applicationName + "', server: { url: import.meta.env.VITE_FELTDB_MANAGED_URL || '', applicationId: import.meta.env.VITE_FELTDB_MANAGED_APPLICATION_ID, environment: 'production' } }"
198
202
  : runtime === 'self-hosted'
199
203
  ? "{ namespace: import.meta.env.VITE_FELTDB_NAMESPACE || '" + applicationName + "', server: { url: import.meta.env.VITE_FELTDB_URL || 'http://localhost:7700', token: import.meta.env.VITE_FELTDB_API_KEY || '' } }"
200
204
  : "{ namespace: import.meta.env.VITE_FELTDB_NAMESPACE || '" + applicationName + "', memory: true }";
@@ -365,7 +369,7 @@ async function logActivity(event: Omit<ActivityEvent, 'id' | 'timestamp'>): Prom
365
369
  `;
366
370
  fs.writeFileSync(path.join(srcDir, 'feltdb.ts'), feltdbTs);
367
371
  // Create a real local-inference agent for browser projects.
368
- if (hasAgents && hasWebLLM) {
372
+ if (hasAgents && hasWebLLM && framework !== 'react') {
369
373
  const agentTs = `import { WebLLMProvider } from '@feltdb/webllm';
370
374
  import { db, reports } from '../../src/feltdb';
371
375
 
@@ -1185,6 +1189,17 @@ root.render(
1185
1189
  );
1186
1190
  `;
1187
1191
  fs.writeFileSync(path.join(srcDir, 'index.tsx'), indexTsx);
1192
+ const canonicalTemplate = path.join(templatesDir, 'default-project');
1193
+ if (fs.existsSync(canonicalTemplate)) {
1194
+ fs.cpSync(path.join(canonicalTemplate, 'src'), srcDir, { recursive: true });
1195
+ for (const directory of ['agents', 'capabilities', 'workflows']) {
1196
+ fs.cpSync(path.join(canonicalTemplate, directory), path.join(projectDir, directory), { recursive: true });
1197
+ fs.rmSync(path.join(feltdbDir, directory), { recursive: true, force: true });
1198
+ }
1199
+ fs.writeFileSync(path.join(projectDir, 'feltdb.flow'), fs.readFileSync(path.join(canonicalTemplate, 'feltdb.flow'), 'utf8').replace('app FeltDBStarter', `app ${appName}`));
1200
+ const databasePath = path.join(srcDir, 'feltdb.ts');
1201
+ fs.writeFileSync(databasePath, fs.readFileSync(databasePath, 'utf8').replace(/export const managedRuntime[\s\S]*?\n\} : \{ namespace: import\.meta\.env\.VITE_FELTDB_NAMESPACE \|\| 'feltdb-starter', browser: true \}\);/, `export const managedRuntime = ${runtime === 'managed'};\nexport const db = createFeltDB(${runtimeOptions});`));
1202
+ }
1188
1203
  }
1189
1204
  else {
1190
1205
  // Create vanilla JS app
@@ -1219,8 +1234,7 @@ main().catch(console.error);
1219
1234
  const envExample = `# FeltDB Configuration
1220
1235
  # Copy this file to .env.local and update the values
1221
1236
 
1222
- # API Key for authenticating with FeltDB servers
1223
- # Leave empty for browser runtime; required for authenticated self-hosted and managed runtimes
1237
+ # API key for authenticated self-hosted browser access
1224
1238
  VITE_FELTDB_API_KEY=
1225
1239
 
1226
1240
  # FeltDB Server URL (self-hosted or managed runtime)
@@ -1228,11 +1242,10 @@ VITE_FELTDB_URL=http://localhost:7700
1228
1242
 
1229
1243
  # Managed example: https://runtime.your-app.feltdb.com
1230
1244
  VITE_FELTDB_MANAGED_URL=
1231
- VITE_FELTDB_MANAGED_API_KEY=
1232
- VITE_FELTDB_MANAGED_TENANT_ID=
1233
1245
  VITE_FELTDB_MANAGED_APPLICATION_ID=
1234
- VITE_FELTDB_MANAGED_NAMESPACE=
1235
- VITE_FELTDB_MANAGED_ENVIRONMENT=production
1246
+ # Server/control-plane only. Never rename with a VITE_ prefix.
1247
+ FELTDB_MANAGED_CONTROL_API_KEY=
1248
+ FELTDB_TOKEN=
1236
1249
 
1237
1250
  # Override the default self-hosted container image
1238
1251
  # By default the server image is built locally from the bundled source.
@@ -1371,7 +1384,7 @@ The self-hosted instance runs on \`http://localhost:7700\` by default.
1371
1384
  }
1372
1385
  \`\`\`
1373
1386
 
1374
- Managed mode uses the same application API with a FeltDB-hosted endpoint. The CLI creates \`.env.local\` with \`VITE_FELTDB_MANAGED_URL\` and \`VITE_FELTDB_MANAGED_API_KEY\` after account setup. Studio uses that same connection for state, health, operations, and API-key administration.
1387
+ Managed mode uses the same application API with a FeltDB-hosted endpoint. The CLI writes only the public URL and application ID plus separate server-only publish and runtime credentials. Tenant, namespace, environment, and active revision are discovered deployment metadata under \`.feltdb/managed.json\`.
1375
1388
 
1376
1389
  ## Vector Search Status
1377
1390
 
@@ -1524,6 +1537,7 @@ build/
1524
1537
  *.log
1525
1538
  .DS_Store
1526
1539
  .feltdb/
1540
+ .feltdb-data/
1527
1541
  `;
1528
1542
  fs.writeFileSync(path.join(projectDir, '.gitignore'), gitignore);
1529
1543
  // Create README
@@ -1639,7 +1653,7 @@ FeltDB runs through a dedicated server with Docker Compose and persistent data v
1639
1653
 
1640
1654
  ### Managed Runtime
1641
1655
 
1642
- FeltDB provides the runtime endpoint, durable storage, synchronization, and background workload infrastructure. The CLI configures \`VITE_FELTDB_MANAGED_URL\` and \`VITE_FELTDB_MANAGED_API_KEY\` in \`.env.local\`.
1656
+ FeltDB provides the runtime endpoint, durable storage, synchronization, and background workload infrastructure. The CLI configures the public \`VITE_FELTDB_MANAGED_*\` identity, separate server-only control and data-plane credentials, and the promoted revision in \`.env.local\`. The browser API-key value stays empty because browser users receive short-lived actor sessions.
1643
1657
 
1644
1658
  ## Configuration
1645
1659
 
@@ -1669,12 +1683,14 @@ Create a \`.env.local\` file (copy from \`.env.example\`):
1669
1683
  \`\`\`
1670
1684
  VITE_FELTDB_API_KEY=your_api_key_here
1671
1685
  VITE_FELTDB_URL=http://localhost:7700
1672
- VITE_FELTDB_MANAGED_API_KEY=your_managed_api_key_here
1673
1686
  VITE_FELTDB_MANAGED_URL=https://api.feltdb.com
1687
+ VITE_FELTDB_MANAGED_APPLICATION_ID=your_application_id
1688
+ FELTDB_MANAGED_CONTROL_API_KEY=your_server_only_control_key
1689
+ FELTDB_TOKEN=your_server_only_application_data_key
1674
1690
  VITE_FELTDB_WEBSOCKET_URL=ws://localhost:7700
1675
1691
  \`\`\`
1676
1692
 
1677
- These are required when connecting to an authenticated self-hosted or managed FeltDB instance.
1693
+ Never expose \`FELTDB_MANAGED_CONTROL_API_KEY\` or \`FELTDB_TOKEN\` through a \`VITE_\` variable. Managed browser access uses \`db.auth.signUp()\` and \`db.auth.signIn()\`; the active revision is discovered rather than configured.
1678
1694
 
1679
1695
  ## Development
1680
1696
 
@@ -1795,6 +1811,12 @@ If port 5173 is in use:
1795
1811
  MIT
1796
1812
  `;
1797
1813
  fs.writeFileSync(path.join(projectDir, 'README.md'), readme);
1814
+ if (framework === 'react') {
1815
+ const canonicalReadme = path.join(templatesDir, 'default-project', 'README.md');
1816
+ if (fs.existsSync(canonicalReadme)) {
1817
+ fs.writeFileSync(path.join(projectDir, 'README.md'), fs.readFileSync(canonicalReadme, 'utf8').replace('# My FeltDB App', `# ${applicationName}`));
1818
+ }
1819
+ }
1798
1820
  // Initialize Development Workspace
1799
1821
  // This creates .feltdb/workspace.json which enables all FeltDB-aware tools
1800
1822
  // (CLI, IDE, agents, browser extensions) to discover and connect to the
@@ -1,5 +1,20 @@
1
1
  import fs from 'fs';
2
2
  import path from 'path';
3
+ function loadEnvironment(file) {
4
+ const values = {};
5
+ for (const raw of fs.readFileSync(file, 'utf8').split(/\r?\n/)) {
6
+ const line = raw.trim();
7
+ if (!line || line.startsWith('#'))
8
+ continue;
9
+ const separator = line.indexOf('=');
10
+ if (separator > 0)
11
+ values[line.slice(0, separator)] = line.slice(separator + 1);
12
+ }
13
+ return values;
14
+ }
15
+ function managedStateFile(projectDir) {
16
+ return path.join(projectDir, '.feltdb', 'managed.json');
17
+ }
3
18
  async function responseJson(response, fallback) {
4
19
  const body = await response.json().catch(() => ({}));
5
20
  if (!response.ok)
@@ -43,26 +58,82 @@ export async function configureManagedAccount(options) {
43
58
  // Provision through the account origin that issued the human session. The
44
59
  // resulting key is still used against apiUrl, but setup must not require a
45
60
  // browser session to be trusted by every managed data-plane hostname.
46
- const key = await responseJson(await request(`${sessionOrigin}/api/keys`, {
61
+ const controlKey = await responseJson(await request(`${sessionOrigin}/api/keys`, {
62
+ method: 'POST', headers,
63
+ body: JSON.stringify({
64
+ id: `${options.applicationName}-control`,
65
+ namespace: application.namespace || options.namespace,
66
+ application_id: application.id,
67
+ scopes: [
68
+ 'application:read', 'application:write', 'application:revision:create',
69
+ 'application:revision:read', 'application:revision:promote',
70
+ 'application:environment:read', 'application:environment:write',
71
+ ],
72
+ }),
73
+ }), 'Could not create the managed control-plane key');
74
+ if (!controlKey.secret)
75
+ throw new Error('Managed key service did not return the control-plane credential');
76
+ const dataKey = await responseJson(await request(`${sessionOrigin}/api/keys`, {
47
77
  method: 'POST', headers,
48
78
  body: JSON.stringify({
49
- id: `${options.applicationName}-cli`,
50
- namespace: options.namespace,
51
- scopes: ['state:read', 'state:write', 'events:read'],
79
+ id: `${options.applicationName}-runtime`,
80
+ namespace: application.namespace || options.namespace,
81
+ application_id: application.id,
82
+ scopes: [
83
+ 'application:read', 'application:revision:read',
84
+ 'state:read', 'state:write', 'events:read',
85
+ 'workflows:run', 'agents:run', 'capabilities:run',
86
+ ],
52
87
  }),
53
- }), 'Could not create the managed application key');
54
- if (!key.secret)
55
- throw new Error('Managed key service did not return an application secret');
88
+ }), 'Could not create the managed application data key');
89
+ if (!dataKey.secret)
90
+ throw new Error('Managed key service did not return the application data credential');
91
+ try {
92
+ await responseJson(await request(`${apiUrl}/api/applications/${encodeURIComponent(application.id)}/revisions`, {
93
+ headers: { Authorization: `Bearer ${controlKey.secret}` },
94
+ }), 'Managed control-plane credential was created but is not accepted by the managed API');
95
+ }
96
+ catch (error) {
97
+ await Promise.allSettled([controlKey.id, dataKey.id].filter(Boolean).map(id => request(`${sessionOrigin}/api/keys/${encodeURIComponent(id)}`, { method: 'DELETE', headers })));
98
+ throw error;
99
+ }
56
100
  const environment = [
57
101
  '# Generated by create-feltdb managed setup. Do not commit this file.',
58
102
  `VITE_FELTDB_MANAGED_URL=${apiUrl}`,
59
- `VITE_FELTDB_MANAGED_API_KEY=${key.secret}`,
60
- `VITE_FELTDB_MANAGED_TENANT_ID=${tenant.id}`,
61
103
  `VITE_FELTDB_MANAGED_APPLICATION_ID=${application.id}`,
62
- `VITE_FELTDB_MANAGED_NAMESPACE=${application.namespace || options.namespace}`,
63
- 'VITE_FELTDB_MANAGED_ENVIRONMENT=production',
104
+ '# Control-plane credential. Never prefix this with VITE_ or expose it to browser code.',
105
+ `FELTDB_MANAGED_CONTROL_API_KEY=${controlKey.secret}`,
106
+ '# Server/deployment data-plane credential. Never prefix this with VITE_.',
107
+ `FELTDB_TOKEN=${dataKey.secret}`,
64
108
  '',
65
109
  ].join('\n');
66
110
  fs.writeFileSync(path.join(options.projectDir, '.env.local'), environment, { mode: 0o600 });
111
+ fs.mkdirSync(path.dirname(managedStateFile(options.projectDir)), { recursive: true });
112
+ fs.writeFileSync(managedStateFile(options.projectDir), `${JSON.stringify({
113
+ url: apiUrl,
114
+ tenantId: tenant.id,
115
+ applicationId: application.id,
116
+ namespace: application.namespace || options.namespace,
117
+ environment: 'production',
118
+ }, null, 2)}\n`, { mode: 0o600 });
67
119
  return { tenant, application, apiUrl };
68
120
  }
121
+ export async function finalizeManagedProvisioning(options) {
122
+ const publishedFile = path.join(options.projectDir, '.feltdb', 'last-published.json');
123
+ if (!fs.existsSync(publishedFile))
124
+ throw new Error('Managed provisioning did not produce .feltdb/last-published.json');
125
+ const published = JSON.parse(fs.readFileSync(publishedFile, 'utf8'));
126
+ if (!published.revisionId)
127
+ throw new Error('Managed provisioning did not promote an application revision');
128
+ const environmentFile = path.join(options.projectDir, '.env.local');
129
+ const environment = loadEnvironment(environmentFile);
130
+ const managedFile = managedStateFile(options.projectDir);
131
+ const managed = JSON.parse(fs.readFileSync(managedFile, 'utf8'));
132
+ const request = options.fetchImpl || fetch;
133
+ const discovery = await responseJson(await request(`${managed.url}/v1/application?application_id=${encodeURIComponent(managed.applicationId)}&environment=${encodeURIComponent(managed.environment)}`, { headers: { Authorization: `Bearer ${environment.FELTDB_TOKEN}` } }), 'Managed application data credential is not accepted by the promoted runtime');
134
+ if (discovery.revision_id !== published.revisionId) {
135
+ throw new Error('Managed runtime revision does not match the revision promoted during provisioning');
136
+ }
137
+ fs.writeFileSync(managedFile, `${JSON.stringify({ ...managed, revisionId: published.revisionId }, null, 2)}\n`, { mode: 0o600 });
138
+ return { revisionId: published.revisionId };
139
+ }
@@ -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.7.3';
3
+ export const FELTDB_PACKAGE_VERSION = '0.8.0';
4
4
  export const feltdbPackageRange = `^${FELTDB_PACKAGE_VERSION}`;
@@ -132,7 +132,10 @@ pub struct CapabilityBinding {
132
132
  pub name: String,
133
133
  #[serde(default)]
134
134
  pub provider: Option<String>,
135
+ #[serde(default = "public_capability_visibility")]
136
+ pub visibility: String,
135
137
  }
138
+ fn public_capability_visibility() -> String { "public".into() }
136
139
  #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
137
140
  pub struct SecretReference {
138
141
  pub binding: String,
@@ -269,6 +272,9 @@ pub struct ApplicationManifest {
269
272
  pub agents: Vec<AgentDefinition>,
270
273
  #[serde(default)]
271
274
  pub capabilities: Vec<CapabilityBinding>,
275
+ /// Resolved, provider-neutral module semantic contracts. Secret values are forbidden.
276
+ #[serde(default)]
277
+ pub modules: Vec<serde_json::Value>,
272
278
  #[serde(default)]
273
279
  pub connections: Vec<ConnectionBinding>,
274
280
  #[serde(default)]
@@ -310,6 +316,7 @@ impl ApplicationManifest {
310
316
  schedules: vec![],
311
317
  agents: vec![],
312
318
  capabilities: vec![],
319
+ modules: vec![],
313
320
  connections: vec![],
314
321
  policies: vec![],
315
322
  ui: UiApplicationModel::default(),
@@ -368,7 +375,14 @@ pub struct ValidationReport {
368
375
  }
369
376
 
370
377
  fn is_valid_policy_subject(subject: &str) -> bool {
371
- matches!(subject, "authenticated" | "owner" | "member")
378
+ subject.split('|').map(str::trim).all(|term| {
379
+ matches!(term, "authenticated" | "owner" | "member")
380
+ || term.strip_prefix("self(").and_then(|value| value.strip_suffix(')')).is_some_and(|field| {
381
+ let mut characters = field.chars();
382
+ characters.next().is_some_and(|value| value == '_' || value.is_ascii_alphabetic())
383
+ && characters.all(|value| value == '_' || value.is_ascii_alphanumeric())
384
+ })
385
+ })
372
386
  }
373
387
 
374
388
  fn unique(
@@ -540,6 +554,11 @@ pub fn validate_manifest(
540
554
  .iter()
541
555
  .map(|v| v.name.as_str())
542
556
  .collect();
557
+ for capability in &manifest.capabilities {
558
+ if !matches!(capability.visibility.as_str(), "public" | "agent" | "workflow" | "internal") {
559
+ issues.push(issue("semantic", format!("capabilities.{}.visibility", capability.name), "visibility must be public, agent, workflow, or internal"));
560
+ }
561
+ }
543
562
  let workflows: BTreeSet<_> = manifest.workflows.iter().map(|v| v.name.as_str()).collect();
544
563
  let queries: BTreeSet<_> = manifest.queries.iter().map(|v| v.name.as_str()).collect();
545
564
  let bindings: BTreeSet<_> = manifest
@@ -718,6 +737,11 @@ pub fn validate_manifest(
718
737
  }
719
738
  }
720
739
  }
740
+ for (index, module) in manifest.modules.iter().enumerate() {
741
+ if contains_plaintext_secret(module) || module.get("value").is_some() {
742
+ issues.push(issue("security", format!("modules.{index}"), "module contracts may declare secret requirements but never secret values"));
743
+ }
744
+ }
721
745
  for environment in &manifest.environments {
722
746
  if environment.configuration.iter().any(|(key, value)| {
723
747
  matches!(
@@ -853,6 +877,7 @@ fn sorted(mut manifest: ApplicationManifest) -> ApplicationManifest {
853
877
  agent.capabilities.sort();
854
878
  }
855
879
  sort_name!(manifest.capabilities);
880
+ manifest.modules.sort_by(|a, b| a.get("id").and_then(Value::as_str).cmp(&b.get("id").and_then(Value::as_str)));
856
881
  sort_name!(manifest.connections);
857
882
  sort_name!(manifest.policies);
858
883
  sort_name!(manifest.ui.bindings);
@@ -2468,6 +2493,21 @@ mod tests {
2468
2493
  );
2469
2494
  }
2470
2495
 
2496
+ #[test]
2497
+ fn policy_with_identity_and_membership_composition_is_valid() {
2498
+ let mut manifest = ApplicationManifest::empty("tenant", "app", "test");
2499
+ manifest.policies.push(PolicyDefinition {
2500
+ name: "InvitationPolicy".into(),
2501
+ resource: "Invitation".into(),
2502
+ read: Some("self(user) | member".into()),
2503
+ write: Some("member".into()),
2504
+ capabilities: vec![],
2505
+ });
2506
+
2507
+ let report = validate_manifest(&manifest, "tenant", "app", None);
2508
+ assert!(report.valid, "composed identity policy should be valid: {:?}", report.issues);
2509
+ }
2510
+
2471
2511
  #[test]
2472
2512
  fn policy_with_invalid_subject_rejected() {
2473
2513
  let mut manifest = ApplicationManifest::empty("tenant", "app", "test");
@@ -283,6 +283,10 @@ pub enum AuthorityError {
283
283
  provided: FencingToken,
284
284
  current: FencingToken,
285
285
  },
286
+ QuorumUnavailable {
287
+ live: usize,
288
+ required: usize,
289
+ },
286
290
  Io(String),
287
291
  }
288
292
 
@@ -296,6 +300,12 @@ impl std::fmt::Display for AuthorityError {
296
300
  Self::StaleToken { provided, current } => {
297
301
  write!(f, "stale token {provided}, current is {current}")
298
302
  }
303
+ Self::QuorumUnavailable { live, required } => {
304
+ write!(
305
+ f,
306
+ "authority quorum unavailable: {live} live, {required} required"
307
+ )
308
+ }
299
309
  Self::Io(msg) => write!(f, "authority store: {msg}"),
300
310
  }
301
311
  }
@@ -308,6 +318,20 @@ impl AuthorityStore {
308
318
  cluster_id: &str,
309
319
  local_node_id: &str,
310
320
  local_priority: u32,
321
+ ) -> Result<Self, AuthorityError> {
322
+ Self::open_with_failure_detector(path, cluster_id, local_node_id, local_priority, 1_000, 5)
323
+ }
324
+
325
+ /// Open with an explicitly configured failure detector. Production keeps
326
+ /// the conservative default above; process-level acceptance tests use a
327
+ /// shorter interval so failure is event-driven without multi-second sleeps.
328
+ pub fn open_with_failure_detector<P: AsRef<Path>>(
329
+ path: P,
330
+ cluster_id: &str,
331
+ local_node_id: &str,
332
+ local_priority: u32,
333
+ heartbeat_interval_ms: u64,
334
+ failure_threshold: u32,
311
335
  ) -> Result<Self, AuthorityError> {
312
336
  let path = path.as_ref().to_path_buf();
313
337
  let state = if path.exists() {
@@ -323,7 +347,7 @@ impl AuthorityStore {
323
347
  Ok(Self {
324
348
  path,
325
349
  state,
326
- heartbeats: HeartbeatState::new(1000, 5), // 1s heartbeat, 5 missed = failure
350
+ heartbeats: HeartbeatState::new(heartbeat_interval_ms, failure_threshold),
327
351
  })
328
352
  }
329
353
 
@@ -333,8 +357,8 @@ impl AuthorityStore {
333
357
  std::fs::create_dir_all(parent).map_err(|e| AuthorityError::Io(e.to_string()))?;
334
358
  }
335
359
  let temp = self.path.with_extension("tmp");
336
- let bytes =
337
- serde_json::to_vec_pretty(&self.state).map_err(|e| AuthorityError::Io(e.to_string()))?;
360
+ let bytes = serde_json::to_vec_pretty(&self.state)
361
+ .map_err(|e| AuthorityError::Io(e.to_string()))?;
338
362
  {
339
363
  use std::io::Write;
340
364
  let mut file =
@@ -416,7 +440,9 @@ impl AuthorityStore {
416
440
 
417
441
  let mut record =
418
442
  AuthorityRecord::new(self.state.local_node_id.clone(), new_token, previous);
419
- record.acknowledgements.insert(self.state.local_node_id.clone());
443
+ record
444
+ .acknowledgements
445
+ .insert(self.state.local_node_id.clone());
420
446
 
421
447
  self.state.current_authority = Some(record);
422
448
  self.state.local_role = AuthorityRole::Authority;
@@ -512,6 +538,31 @@ impl AuthorityStore {
512
538
  Ok(())
513
539
  }
514
540
 
541
+ /// Validate an authoritative write against both its fencing term and a
542
+ /// live quorum. A partitioned former authority may not have observed the
543
+ /// replacement's newer token yet, so fencing alone is insufficient until
544
+ /// healing; expiry of its quorum evidence closes that split-brain window.
545
+ pub fn validate_quorum_write(
546
+ &self,
547
+ token: FencingToken,
548
+ required_quorum: usize,
549
+ ) -> Result<(), AuthorityError> {
550
+ self.validate_write(Some(token))?;
551
+ let live = 1 + self
552
+ .heartbeats
553
+ .last_seen
554
+ .keys()
555
+ .filter(|node_id| !self.heartbeats.is_failed(node_id))
556
+ .count();
557
+ if live < required_quorum {
558
+ return Err(AuthorityError::QuorumUnavailable {
559
+ live,
560
+ required: required_quorum,
561
+ });
562
+ }
563
+ Ok(())
564
+ }
565
+
515
566
  /// Step down from authority role (voluntary).
516
567
  pub fn step_down(&mut self) -> Result<(), AuthorityError> {
517
568
  if self.state.local_role == AuthorityRole::Authority {
@@ -746,13 +797,40 @@ mod tests {
746
797
  assert!(heartbeats.is_failed("A"));
747
798
  }
748
799
 
800
+ #[test]
801
+ fn partitioned_authority_loses_write_quorum() {
802
+ let dir = tempfile::tempdir().unwrap();
803
+ let mut store = AuthorityStore::open_with_failure_detector(
804
+ dir.path().join("authority.json"),
805
+ "test-cluster",
806
+ "A",
807
+ 10,
808
+ 1,
809
+ 1,
810
+ )
811
+ .expect("open authority store");
812
+ let token = match store.claim_authority().expect("claim") {
813
+ ClaimResult::Granted { token } => token,
814
+ _ => panic!("authority claim was not granted"),
815
+ };
816
+ store.heartbeat("B");
817
+ assert!(store.validate_quorum_write(token, 2).is_ok());
818
+ store.heartbeats.last_seen.insert("B".to_string(), 0);
819
+ assert!(matches!(
820
+ store.validate_quorum_write(token, 2),
821
+ Err(AuthorityError::QuorumUnavailable {
822
+ live: 1,
823
+ required: 2
824
+ })
825
+ ));
826
+ }
827
+
749
828
  #[test]
750
829
  fn recovery_from_fenced() {
751
830
  let dir = tempfile::tempdir().unwrap();
752
831
  let path = dir.path().join("authority.json");
753
832
 
754
- let mut store =
755
- AuthorityStore::open(&path, "test-cluster", "A", 10).expect("open store");
833
+ let mut store = AuthorityStore::open(&path, "test-cluster", "A", 10).expect("open store");
756
834
  store.claim_authority().expect("claim");
757
835
 
758
836
  // Get fenced