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.
- package/dist/cli.js +11 -2
- package/dist/create.js +38 -16
- package/dist/managed-account.js +82 -11
- package/dist/package-versions.js +1 -1
- package/dist/server-source/crates/feltdb/src/application.rs +41 -1
- package/dist/server-source/crates/feltdb/src/authority_failover.rs +84 -6
- package/dist/server-source/crates/feltdb/src/bin/feltdb_node.rs +126 -38
- package/dist/server-source/crates/feltdb/src/distributed_transactions.rs +72 -34
- package/dist/server-source/crates/feltdb/src/lib.rs +20 -2
- package/dist/server-source/crates/feltdb-server/src/app_state.rs +12 -24
- package/dist/server-source/crates/feltdb-server/src/application_contract.rs +11 -3
- package/dist/server-source/crates/feltdb-server/src/authenticated_principal.rs +0 -1
- package/dist/server-source/crates/feltdb-server/src/certification_harness.rs +23 -22
- package/dist/server-source/crates/feltdb-server/src/delegation_token.rs +5 -15
- package/dist/server-source/crates/feltdb-server/src/durable_operations.rs +13 -28
- package/dist/server-source/crates/feltdb-server/src/identity.rs +111 -3
- package/dist/server-source/crates/feltdb-server/src/key_management.rs +28 -7
- package/dist/server-source/crates/feltdb-server/src/lib.rs +5 -5
- package/dist/server-source/crates/feltdb-server/src/main.rs +1960 -143
- package/dist/server-source/crates/feltdb-server/src/managed_diagnostics.rs +10 -30
- package/dist/server-source/crates/feltdb-server/src/membership_policy.rs +12 -4
- package/dist/server-source/crates/feltdb-server/src/request_telemetry.rs +8 -6
- package/dist/server-source/crates/feltdb-server/src/snapshot_cursor.rs +10 -14
- package/dist/server-source/crates/feltdb-server/src/tenancy.rs +195 -13
- package/dist/server-source/crates/feltdb-server/src/tenant_policies.rs +9 -21
- package/dist/server-source/crates/feltdb-server/src/transaction_idempotency.rs +5 -3
- package/dist/server-source/crates/feltdb-server/src/transaction_recovery.rs +9 -8
- package/dist/server-source/crates/feltdb-server/tests/revision_recovery_integration_test.rs +1 -4
- package/dist/template/default-project/README.md +57 -0
- package/dist/template/default-project/agents/activity-assistant.ts +18 -0
- package/dist/template/default-project/agents/project-assistant.ts +19 -0
- package/dist/template/default-project/capabilities/activity-summary.ts +14 -0
- package/dist/template/default-project/capabilities/project-search.ts +16 -0
- package/dist/template/default-project/capabilities/project-summary.ts +15 -0
- package/dist/template/default-project/feltdb.flow +174 -0
- package/dist/template/default-project/src/App.tsx +20 -0
- package/dist/template/default-project/src/context/AuthContext.tsx +27 -0
- package/dist/template/default-project/src/feltdb.ts +87 -0
- package/dist/template/default-project/src/index.tsx +14 -0
- package/dist/template/default-project/src/pages/Activity.tsx +7 -0
- package/dist/template/default-project/src/pages/Agents.tsx +15 -0
- package/dist/template/default-project/src/pages/Dashboard.tsx +17 -0
- package/dist/template/default-project/src/pages/Invitations.tsx +11 -0
- package/dist/template/default-project/src/pages/Projects.tsx +11 -0
- package/dist/template/default-project/src/pages/SignIn.tsx +8 -0
- package/dist/template/default-project/src/pages/SignUp.tsx +8 -0
- package/dist/template/default-project/src/styles-application.css +21 -0
- package/dist/template/default-project/src/styles.css +1 -0
- package/dist/template/default-project/workflows/agent-assisted-summary.ts +1 -0
- package/dist/template/default-project/workflows/invitation.ts +30 -0
- package/dist/template/default-project/workflows/project-created.ts +2 -0
- 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, '
|
|
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
|
|
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: '
|
|
182
|
+
rootDir: '.',
|
|
180
183
|
strict: true,
|
|
181
184
|
esModuleInterop: true,
|
|
182
185
|
skipLibCheck: true,
|
|
183
186
|
forceConsistentCasingInFileNames: true,
|
|
184
|
-
moduleResolution: '
|
|
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:
|
|
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
|
|
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
|
-
|
|
1235
|
-
|
|
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
|
|
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 \`
|
|
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
|
-
|
|
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
|
package/dist/managed-account.js
CHANGED
|
@@ -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
|
|
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}-
|
|
50
|
-
namespace: options.namespace,
|
|
51
|
-
|
|
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 (!
|
|
55
|
-
throw new Error('Managed key service did not return
|
|
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
|
-
|
|
63
|
-
|
|
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
|
+
}
|
package/dist/package-versions.js
CHANGED
|
@@ -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.
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
|
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
|