@feltdb/core 0.12.2 → 0.15.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/agent-runtime.d.ts +64 -2
- package/dist/agent-runtime.d.ts.map +1 -1
- package/dist/agent-runtime.js +121 -11
- package/dist/agent-state.d.ts +48 -0
- package/dist/agent-state.d.ts.map +1 -1
- package/dist/agent-state.js +174 -12
- package/dist/agent.d.ts +10 -1
- package/dist/agent.d.ts.map +1 -1
- package/dist/agent.js +10 -1
- package/dist/build-identity.json +4 -4
- package/dist/cli/commands.js +34 -7
- package/dist/cli/workspace-integration.js +8 -0
- package/dist/collection.d.ts +3 -1
- package/dist/collection.d.ts.map +1 -1
- package/dist/collection.js +30 -8
- package/dist/create/build-identity.json +4 -4
- package/dist/create/cli.js +47 -38
- package/dist/create/create.js +21 -8
- package/dist/create/package-versions.js +1 -1
- package/dist/create/server-source/Cargo.lock +6 -6
- package/dist/create/server-source/Cargo.toml +1 -1
- package/dist/create/server-source/crates/feltdb/src/lib.rs +126 -35
- package/dist/create/server-source/crates/feltdb/src/state_contract.rs +162 -53
- package/dist/create/server-source/crates/feltdb/src/worker_mesh.rs +341 -2
- package/dist/create/server-source/crates/feltdb/src/workload.rs +2094 -126
- package/dist/create/server-source/crates/feltdb/tests/production_readiness_contract.rs +18 -12
- package/dist/create/server-source/crates/feltdb/tests/workload_envelope_contract.rs +17 -5
- package/dist/create/server-source/crates/feltdb-client/Cargo.toml +1 -1
- package/dist/create/server-source/crates/feltdb-server/Cargo.toml +1 -1
- package/dist/create/server-source/crates/feltdb-server/README.md +6 -1
- package/dist/create/server-source/crates/feltdb-server/src/agent_execution.rs +511 -0
- package/dist/create/server-source/crates/feltdb-server/src/app_state.rs +9 -1
- package/dist/create/server-source/crates/feltdb-server/src/auth.rs +113 -25
- package/dist/create/server-source/crates/feltdb-server/src/backup.rs +4 -0
- package/dist/create/server-source/crates/feltdb-server/src/console_accounts.rs +1009 -0
- package/dist/create/server-source/crates/feltdb-server/src/control_authorization.rs +441 -0
- package/dist/create/server-source/crates/feltdb-server/src/identity.rs +68 -6
- package/dist/create/server-source/crates/feltdb-server/src/key_management.rs +355 -23
- package/dist/create/server-source/crates/feltdb-server/src/lib.rs +4 -0
- package/dist/create/server-source/crates/feltdb-server/src/main.rs +1740 -236
- package/dist/create/server-source/crates/feltdb-server/src/redaction.rs +208 -0
- package/dist/create/server-source/crates/feltdb-server/src/tenancy.rs +198 -19
- package/dist/create/server-source/crates/feltdb-server/tests/transaction_precondition_contract.rs +403 -0
- package/dist/create/setup-summary.js +40 -0
- package/dist/db.d.ts +91 -10
- package/dist/db.d.ts.map +1 -1
- package/dist/db.js +170 -31
- package/dist/deployment.d.ts +19 -0
- package/dist/deployment.d.ts.map +1 -1
- package/dist/deployment.js +38 -2
- package/dist/development-runtime-bridge.d.ts +20 -0
- package/dist/development-runtime-bridge.d.ts.map +1 -1
- package/dist/development-runtime-bridge.js +61 -12
- package/dist/embedded-query.d.ts.map +1 -1
- package/dist/embedded-query.js +5 -4
- package/dist/embedded-transaction.d.ts +8 -4
- package/dist/embedded-transaction.d.ts.map +1 -1
- package/dist/embedded-transaction.js +55 -19
- package/dist/error-codes.d.ts +31 -2
- package/dist/error-codes.d.ts.map +1 -1
- package/dist/error-codes.js +41 -2
- package/dist/feltdb.d.ts +7 -0
- package/dist/feltdb.d.ts.map +1 -1
- package/dist/file-db.d.ts +27 -0
- package/dist/file-db.d.ts.map +1 -1
- package/dist/file-db.js +145 -7
- package/dist/http-client.d.ts +11 -3
- package/dist/http-client.d.ts.map +1 -1
- package/dist/http-client.js +27 -14
- package/dist/http-db.d.ts +40 -1
- package/dist/http-db.d.ts.map +1 -1
- package/dist/http-db.js +285 -58
- package/dist/index.js +1 -1
- package/dist/local-state-lock.d.ts +34 -0
- package/dist/local-state-lock.d.ts.map +1 -0
- package/dist/local-state-lock.js +225 -0
- package/dist/reactive-graph.d.ts +0 -3
- package/dist/reactive-graph.d.ts.map +1 -1
- package/dist/reactive-graph.js +11 -1
- package/dist/runtime-registry.d.ts +7 -2
- package/dist/runtime-registry.d.ts.map +1 -1
- package/dist/runtime-registry.js +2 -2
- package/dist/studio/{KeyManagementPanel-D5FMvvU9.js → KeyManagementPanel-skUNnKRw.js} +36 -29
- package/dist/studio/app.d.ts +8 -1
- package/dist/studio/app.d.ts.map +1 -1
- package/dist/studio/components/KeyManagementPanel.d.ts.map +1 -1
- package/dist/studio/components/KeyManagementPanel.js +1 -1
- package/dist/studio/components/index.js +2 -2
- package/dist/studio/{components-B4eKIUN5.js → components-CJmzntBi.js} +8 -7
- package/dist/studio/control-plane/Data.d.ts.map +1 -1
- package/dist/studio/control-plane/Instance.d.ts +4 -1
- package/dist/studio/control-plane/Instance.d.ts.map +1 -1
- package/dist/studio/control-plane/Operations.d.ts +4 -1
- package/dist/studio/control-plane/Operations.d.ts.map +1 -1
- package/dist/studio/control-plane/index.js +2 -2
- package/dist/studio/control-plane/primitives.d.ts +16 -0
- package/dist/studio/control-plane/primitives.d.ts.map +1 -1
- package/dist/studio/{control-plane-fxMcJn74.js → control-plane-CaBA7Rgv.js} +448 -424
- package/dist/studio/index.d.ts +1 -0
- package/dist/studio/index.d.ts.map +1 -1
- package/dist/studio/index.js +384 -247
- package/dist/studio/navigation.d.ts +57 -0
- package/dist/studio/navigation.d.ts.map +1 -0
- package/dist/studio/studio.css +1 -1
- package/dist/studio/utils/control-plane-api.d.ts +2 -0
- package/dist/studio/utils/control-plane-api.d.ts.map +1 -1
- package/dist/studio/utils/control-plane-api.js +5 -1
- package/dist/studio-app/assets/{feltdb_wasm-BatwwogY.js → feltdb_wasm-BjM1ylL3.js} +1 -1
- package/dist/studio-app/assets/feltdb_wasm_bg-TseJn2Ax.wasm +0 -0
- package/dist/studio-app/assets/index-1wd0uKua.js +30 -0
- package/dist/studio-app/assets/{index-CiC8VY-a.css → index-vi21oEQa.css} +1 -1
- package/dist/studio-app/index.html +2 -2
- package/dist/transaction.d.ts +27 -11
- package/dist/transaction.d.ts.map +1 -1
- package/dist/transaction.js +20 -14
- package/dist/wasm/feltdb_wasm_bg.wasm +0 -0
- package/dist/wasm/package.json +1 -1
- package/dist/workload.d.ts +31 -3
- package/dist/workload.d.ts.map +1 -1
- package/dist/workload.js +28 -3
- package/dist/workspace/local-development-authority.d.ts +19 -0
- package/dist/workspace/local-development-authority.d.ts.map +1 -1
- package/dist/workspace/local-development-authority.js +335 -15
- package/package.json +1 -1
- package/dist/studio-app/assets/feltdb_wasm_bg-BX0newOr.wasm +0 -0
- package/dist/studio-app/assets/index-DoVvbIay.js +0 -30
package/dist/workload.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { FeltDBErrorCode, FeltDBServiceError } from './error-codes.js';
|
|
1
2
|
export class WorkloadClient {
|
|
2
3
|
baseUrl;
|
|
3
4
|
fetcher;
|
|
@@ -5,8 +6,32 @@ export class WorkloadClient {
|
|
|
5
6
|
this.baseUrl = baseUrl;
|
|
6
7
|
this.fetcher = fetcher;
|
|
7
8
|
}
|
|
8
|
-
|
|
9
|
-
|
|
9
|
+
/**
|
|
10
|
+
* Refusals are `FeltDBServiceError`s carrying the authority's code, status and body (`details` reads its details). A transport failure on a
|
|
11
|
+
* write is `COMMIT_OUTCOME_UNKNOWN`: the report may have landed, so retry the same report (same fence, result id
|
|
12
|
+
* and output), which the authority replays, rather than a different one.
|
|
13
|
+
*/
|
|
14
|
+
async json(path, init) {
|
|
15
|
+
let response;
|
|
16
|
+
try {
|
|
17
|
+
response = await this.fetcher(`${this.baseUrl}${path}`, { credentials: 'include', headers: { 'content-type': 'application/json', ...(init?.headers || {}) }, ...init });
|
|
18
|
+
}
|
|
19
|
+
catch (error) {
|
|
20
|
+
const mutating = (init?.method ?? 'GET').toUpperCase() !== 'GET';
|
|
21
|
+
throw new FeltDBServiceError(error instanceof Error ? error.message : String(error), mutating ? FeltDBErrorCode.COMMIT_OUTCOME_UNKNOWN : FeltDBErrorCode.SERVICE_UNAVAILABLE);
|
|
22
|
+
}
|
|
23
|
+
const text = response.status === 204 ? '' : await response.text();
|
|
24
|
+
let value;
|
|
25
|
+
try {
|
|
26
|
+
value = text ? JSON.parse(text) : undefined;
|
|
27
|
+
}
|
|
28
|
+
catch {
|
|
29
|
+
value = { message: text };
|
|
30
|
+
}
|
|
31
|
+
if (!response.ok)
|
|
32
|
+
throw new FeltDBServiceError(value?.error || value?.message || `Workload request failed (${response.status})`, value?.code || value?.error || `HTTP_${response.status}`, value?.request_id, response.status, value && typeof value === 'object' && !Array.isArray(value) ? value : undefined);
|
|
33
|
+
return value;
|
|
34
|
+
}
|
|
10
35
|
create(input) { return this.json('/v1/workloads', { method: 'POST', body: JSON.stringify({ ...input, created_at: input.created_at || Math.floor(Date.now() / 1000) }) }); }
|
|
11
36
|
list(applicationId) { return this.json(`/v1/workloads?application_id=${encodeURIComponent(applicationId)}`); }
|
|
12
37
|
inspect(id) { return this.json(`/v1/workloads/${encodeURIComponent(id)}`); }
|
|
@@ -19,7 +44,7 @@ export class WorkloadClient {
|
|
|
19
44
|
confirmCancelled(id, fence) { return this.json(`/v1/workloads/${encodeURIComponent(id)}/cancelled`, { method: 'POST', body: JSON.stringify(fence) }); }
|
|
20
45
|
executionContext(id, input) { return this.json(`/v1/workloads/${encodeURIComponent(id)}/context`, { method: 'POST', body: JSON.stringify(input) }); }
|
|
21
46
|
cancel(id, grant) { return this.json(`/v1/workloads/${encodeURIComponent(id)}/cancel`, { method: 'POST', body: JSON.stringify({ grant }) }); }
|
|
22
|
-
retry(id, grant) { return this.json(`/v1/workloads/${encodeURIComponent(id)}/retry`, { method: 'POST', body: JSON.stringify({ grant }) }); }
|
|
47
|
+
retry(id, grant, reason) { return this.json(`/v1/workloads/${encodeURIComponent(id)}/retry`, { method: 'POST', body: JSON.stringify(reason === undefined ? { grant } : { grant, reason }) }); }
|
|
23
48
|
history(id) { return this.json(`/v1/workloads/${encodeURIComponent(id)}/history`); }
|
|
24
49
|
result(id) { return this.json(`/v1/workloads/${encodeURIComponent(id)}/result`); }
|
|
25
50
|
}
|
|
@@ -20,6 +20,25 @@ export interface LocalDevelopmentAuthorityOptions {
|
|
|
20
20
|
applicationUrl?: string;
|
|
21
21
|
};
|
|
22
22
|
}
|
|
23
|
+
export declare const DEVELOPMENT_CONTROL_PLANE_CAPABILITIES: {
|
|
24
|
+
contract: string;
|
|
25
|
+
version: number;
|
|
26
|
+
capabilities: ({
|
|
27
|
+
status: "available";
|
|
28
|
+
note: string;
|
|
29
|
+
area: string;
|
|
30
|
+
title: string;
|
|
31
|
+
surface: string[];
|
|
32
|
+
id: string;
|
|
33
|
+
} | {
|
|
34
|
+
id: string;
|
|
35
|
+
area: string;
|
|
36
|
+
title: string;
|
|
37
|
+
status: "unsupported";
|
|
38
|
+
surface: string[];
|
|
39
|
+
note: string;
|
|
40
|
+
})[];
|
|
41
|
+
};
|
|
23
42
|
/** Start the durable, unauthenticated loopback authority used by `feltdb dev`. */
|
|
24
43
|
export declare function startLocalDevelopmentAuthority(options: LocalDevelopmentAuthorityOptions): Promise<LocalDevelopmentAuthority>;
|
|
25
44
|
//# sourceMappingURL=local-development-authority.d.ts.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"local-development-authority.d.ts","sourceRoot":"","sources":["../../src/workspace/local-development-authority.ts"],"names":[],"mappings":"
|
|
1
|
+
{"version":3,"file":"local-development-authority.d.ts","sourceRoot":"","sources":["../../src/workspace/local-development-authority.ts"],"names":[],"mappings":"AAIA,OAAO,EAAwD,KAAK,eAAe,EAAsB,MAAM,gBAAgB,CAAC;AAEhI,OAAO,KAAK,EAAE,qBAAqB,EAAE,MAAM,iCAAiC,CAAC;AAE7E,MAAM,WAAW,yBAAyB;IACxC,QAAQ,EAAE,MAAM,CAAC;IACjB,sBAAsB,EAAE,MAAM,CAAC;IAC/B,KAAK,IAAI,OAAO,CAAC,IAAI,CAAC,CAAC;CACxB;AAED,MAAM,WAAW,gCAAgC;IAC/C,OAAO,EAAE,MAAM,CAAC;IAChB,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,eAAe,CAAC,EAAE,MAAM,OAAO,CAAC,eAAe,CAAC,CAAC;IACjD,kBAAkB,CAAC,EAAE,MAAM,qBAAqB,EAAE,GAAG,OAAO,CAAC,qBAAqB,EAAE,CAAC,CAAC;IACtF,cAAc,CAAC,EAAE;QACf,SAAS,EAAE,MAAM,CAAC;QAClB,OAAO,EAAE,MAAM,CAAC;QAChB,OAAO,EAAE,MAAM,CAAC;QAChB,WAAW,EAAE,OAAO,CAAC;QACrB,oBAAoB,EAAE,MAAM,CAAC;QAC7B,cAAc,CAAC,EAAE,MAAM,CAAC;KACzB,CAAC;CACH;AAiFD,eAAO,MAAM,sCAAsC;;;;;;cA1Dd,MAAM;eAAS,MAAM;iBAAW,MAAM,EAAE;;;;;;;iBA+DL,MAAM,EAAE;;;CAC/E,CAAC;AAEF,kFAAkF;AAClF,wBAAsB,8BAA8B,CAClD,OAAO,EAAE,gCAAgC,GACxC,OAAO,CAAC,yBAAyB,CAAC,CAsWpC"}
|
|
@@ -1,19 +1,91 @@
|
|
|
1
1
|
import http from 'node:http';
|
|
2
|
+
import { createHash, randomBytes, scryptSync, timingSafeEqual } from 'node:crypto';
|
|
2
3
|
import path from 'node:path';
|
|
3
4
|
import { FileJsDb } from '../file-db.js';
|
|
4
5
|
import { FELTDB_CONVERGENCE_COLLECTION, FeltDBProposalService } from '../proposal.js';
|
|
5
6
|
import { IntentService } from '../intent.js';
|
|
6
|
-
|
|
7
|
+
/**
|
|
8
|
+
* Why the development authority does not serve a part of the control plane.
|
|
9
|
+
*
|
|
10
|
+
* `feltdb dev` runs a lightweight authority that holds application state and
|
|
11
|
+
* the application contract. Everything else the control plane names belongs to
|
|
12
|
+
* a deployed feltdb-server. Each capability is declared — available or not —
|
|
13
|
+
* so Studio can tell "not part of this instance" from "unknown to Studio", and
|
|
14
|
+
* the reason says where the feature does live.
|
|
15
|
+
*/
|
|
16
|
+
const SERVER_ONLY = {
|
|
17
|
+
data: 'Record inspection, writes, sorting and index management through the control plane are served by feltdb-server. In development, the application writes its own state, and Data shows it.',
|
|
18
|
+
state: 'Semantic history, diff, conflict classification and reconciliation are served by feltdb-server; the development authority keeps current state only.',
|
|
19
|
+
operations: 'Operation history, rejections, transaction status and audit are recorded by feltdb-server; the development authority does not record them.',
|
|
20
|
+
agents: 'Agent principals, sessions, authorization decisions and Durable Work run on feltdb-server. The development authority does not run agents; the agents your application declares are listed under Build › Agents.',
|
|
21
|
+
replication: 'Replication health is reported by a feltdb-server cluster; the development authority is a single local node.',
|
|
22
|
+
applications: 'Managing applications is part of a deployed feltdb-server; a development session serves exactly one application.',
|
|
23
|
+
monitoring: 'Instance metrics come from feltdb-server; the development authority does not export them.',
|
|
24
|
+
settings: 'Instance settings belong to a deployed feltdb-server; a development session is configured by feltdb.config.json.',
|
|
25
|
+
developer: 'Developer access is issued by feltdb-server; the development authority is unauthenticated and loopback-only.',
|
|
26
|
+
};
|
|
27
|
+
const SERVED = {
|
|
28
|
+
'overview.instance': { area: 'overview', title: 'Development authority overview', surface: ['GET /v1/control-plane/overview'] },
|
|
29
|
+
'data.collections.list': { area: 'data', title: 'List declared collections', surface: ['GET /v1/application/schema'] },
|
|
30
|
+
'data.schema.inspect': { area: 'data', title: 'Inspect the application schema', surface: ['GET /v1/application/schema'] },
|
|
31
|
+
'data.records.browse': { area: 'data', title: 'Browse records', surface: ['GET /v1/data/{collection}'] },
|
|
32
|
+
};
|
|
33
|
+
/**
|
|
34
|
+
* Every capability the feltdb-server control plane declares, in its order,
|
|
35
|
+
* with the area it belongs to. Held equal to `control_plane.rs` by
|
|
36
|
+
* `development-authority-capabilities.test.mjs`.
|
|
37
|
+
*/
|
|
38
|
+
const CONTROL_PLANE_IDS = [
|
|
39
|
+
['overview.instance', 'overview', 'Instance overview'],
|
|
40
|
+
['data.collections.list', 'data', 'List collections'],
|
|
41
|
+
['data.records.browse', 'data', 'Browse records'],
|
|
42
|
+
['data.records.inspect', 'data', 'Inspect a record'],
|
|
43
|
+
['data.records.create', 'data', 'Create records'],
|
|
44
|
+
['data.records.update', 'data', 'Update records'],
|
|
45
|
+
['data.records.delete', 'data', 'Delete records'],
|
|
46
|
+
['data.records.sort', 'data', 'Sort records'],
|
|
47
|
+
['data.schema.inspect', 'data', 'Inspect the schema'],
|
|
48
|
+
['data.indexes.inspect', 'data', 'Inspect indexes'],
|
|
49
|
+
['data.indexes.rebuild', 'data', 'Rebuild indexes'],
|
|
50
|
+
['data.records.history', 'state', 'Record history'],
|
|
51
|
+
['state.resources.list', 'state', 'List state resources'],
|
|
52
|
+
['state.history', 'state', 'State history'],
|
|
53
|
+
['state.diff.semantic', 'state', 'Semantic diff'],
|
|
54
|
+
['state.conflict.classify', 'state', 'Classify conflicts'],
|
|
55
|
+
['state.reconciliation.plan', 'state', 'Plan reconciliation'],
|
|
56
|
+
['state.reconciliation.execute', 'state', 'Execute reconciliation'],
|
|
57
|
+
['state.branch.heads', 'state', 'Branch heads'],
|
|
58
|
+
['provenance.record', 'state', 'Record provenance'],
|
|
59
|
+
['provenance.transactions', 'operations', 'Recent attributed transactions'],
|
|
60
|
+
['agents.list', 'agents', 'List agents'],
|
|
61
|
+
['agents.identity', 'agents', 'Agent identity'],
|
|
62
|
+
['agents.register', 'agents', 'Register agents'],
|
|
63
|
+
['agents.work', 'agents', 'Agent work (Durable Work)'],
|
|
64
|
+
['agents.work.review_gate', 'agents', 'Agent work review gate'],
|
|
65
|
+
['agents.sessions', 'agents', 'Agent sessions'],
|
|
66
|
+
['agents.authorization_decisions', 'agents', 'Agent authorization decisions'],
|
|
67
|
+
['agents.task_context', 'agents', 'Agent task context'],
|
|
68
|
+
['operations.recent', 'operations', 'Recent operations'],
|
|
69
|
+
['operations.rejections', 'operations', 'Rejected operations'],
|
|
70
|
+
['operations.events', 'operations', 'Operation events'],
|
|
71
|
+
['operations.transaction_status', 'operations', 'Transaction status'],
|
|
72
|
+
['operations.correlation_id', 'operations', 'Correlation ids'],
|
|
73
|
+
['operations.audit_log', 'operations', 'Audit log'],
|
|
74
|
+
['replication.health', 'replication', 'Replication health'],
|
|
75
|
+
['applications.list', 'applications', 'List applications'],
|
|
76
|
+
['monitoring.metrics', 'monitoring', 'Instance metrics'],
|
|
77
|
+
['monitoring.latency', 'monitoring', 'Latency'],
|
|
78
|
+
['settings.instance', 'settings', 'Instance settings'],
|
|
79
|
+
['settings.mutate', 'settings', 'Change settings'],
|
|
80
|
+
['developer.tool_projection', 'developer', 'Tool projection'],
|
|
81
|
+
['developer.access', 'developer', 'Developer access'],
|
|
82
|
+
];
|
|
83
|
+
export const DEVELOPMENT_CONTROL_PLANE_CAPABILITIES = {
|
|
7
84
|
contract: 'feltdb.control-plane.capabilities',
|
|
8
85
|
version: 1,
|
|
9
|
-
capabilities: [
|
|
10
|
-
{ id
|
|
11
|
-
{ id
|
|
12
|
-
{ id: 'data.schema.inspect', area: 'data', title: 'Inspect the application schema', status: 'available', surface: ['GET /v1/application/schema'], note: '' },
|
|
13
|
-
{ id: 'data.records.browse', area: 'data', title: 'Browse records', status: 'available', surface: ['GET /v1/data/{collection}'], note: '' },
|
|
14
|
-
{ id: 'provenance.transactions', area: 'operations', title: 'Recent attributed transactions', status: 'unsupported', surface: [], note: 'Transaction observability is not available from the lightweight development authority.' },
|
|
15
|
-
{ id: 'replication.health', area: 'replication', title: 'Replication health', status: 'unsupported', surface: [], note: 'Replication health is not available from the lightweight development authority.' },
|
|
16
|
-
],
|
|
86
|
+
capabilities: CONTROL_PLANE_IDS.map(([id, area, title]) => SERVED[id]
|
|
87
|
+
? { id, ...SERVED[id], status: 'available', note: '' }
|
|
88
|
+
: { id, area, title, status: 'unsupported', surface: [], note: SERVER_ONLY[area] }),
|
|
17
89
|
};
|
|
18
90
|
/** Start the durable, unauthenticated loopback authority used by `feltdb dev`. */
|
|
19
91
|
export async function startLocalDevelopmentAuthority(options) {
|
|
@@ -37,9 +109,26 @@ export async function startLocalDevelopmentAuthority(options) {
|
|
|
37
109
|
const proposalService = options.proposalContext ? new FeltDBProposalService(store, options.proposalContext, proposalAuthorityToken) : undefined;
|
|
38
110
|
const intentService = proposalService && options.proposalContext ? new IntentService(store, options.proposalContext, proposalService, options.intentObservations) : undefined;
|
|
39
111
|
const streams = new Set();
|
|
112
|
+
const notify = (collection) => {
|
|
113
|
+
// `resource` is the field every client reads (the event contract
|
|
114
|
+
// feltdb-server serves); `change` is kept for readers of the older frame.
|
|
115
|
+
const frame = `data: ${JSON.stringify({ resource: collection, change: { capability: collection } })}\n\n`;
|
|
116
|
+
for (const stream of streams)
|
|
117
|
+
stream.write(frame);
|
|
118
|
+
};
|
|
40
119
|
let endpoint = '';
|
|
41
120
|
const server = http.createServer(async (request, response) => {
|
|
42
121
|
try {
|
|
122
|
+
// The authority is unauthenticated, so which pages may reach it is the
|
|
123
|
+
// whole boundary: only a loopback origin (the developer's own application
|
|
124
|
+
// and Studio) and a loopback Host (not a rebound DNS name). A request
|
|
125
|
+
// without an Origin is not a browser page and is not restricted here.
|
|
126
|
+
if (!loopbackHost(request.headers.host) || (request.headers.origin !== undefined && !loopbackOrigin(request.headers.origin))) {
|
|
127
|
+
response.statusCode = 403;
|
|
128
|
+
response.setHeader('Content-Type', 'application/json');
|
|
129
|
+
response.end(JSON.stringify({ error: 'NON_LOOPBACK_ORIGIN', message: 'The development authority serves loopback origins only.' }));
|
|
130
|
+
return;
|
|
131
|
+
}
|
|
43
132
|
response.setHeader('Access-Control-Allow-Origin', '*');
|
|
44
133
|
response.setHeader('Access-Control-Allow-Methods', 'GET, POST, PATCH, DELETE, OPTIONS');
|
|
45
134
|
response.setHeader('Access-Control-Allow-Headers', 'Authorization, Content-Type, FeltDB-Protocol, FeltDB-Proposal-Authority');
|
|
@@ -349,14 +438,106 @@ export async function startLocalDevelopmentAuthority(options) {
|
|
|
349
438
|
json(response, 405, { error: 'METHOD_NOT_ALLOWED' });
|
|
350
439
|
return;
|
|
351
440
|
}
|
|
441
|
+
const authMatch = pathname.match(/^\/v1\/auth\/(signup|signin|signout|session)$/);
|
|
442
|
+
if (authMatch) {
|
|
443
|
+
const operation = authMatch[1];
|
|
444
|
+
const expectedMethod = operation === 'session' ? 'GET' : 'POST';
|
|
445
|
+
if (request.method !== expectedMethod) {
|
|
446
|
+
json(response, 405, { error: 'METHOD_NOT_ALLOWED' });
|
|
447
|
+
return;
|
|
448
|
+
}
|
|
449
|
+
const body = operation === 'signup' || operation === 'signin' ? parseObject(await readBody(request)) : {};
|
|
450
|
+
const bearer = String(request.headers.authorization || '').replace(/^Bearer\s+/i, '');
|
|
451
|
+
const outcome = developmentAuth(db, operation, body, bearer);
|
|
452
|
+
json(response, outcome.status, outcome.body);
|
|
453
|
+
return;
|
|
454
|
+
}
|
|
455
|
+
// The direct data plane `HttpJsDb` speaks to any authority, so an
|
|
456
|
+
// application connected to this one behaves as it would against
|
|
457
|
+
// feltdb-server: same routes, same shapes, same refusals.
|
|
458
|
+
if (request.method === 'GET' && pathname === '/revision') {
|
|
459
|
+
json(response, 200, { revision: db.get_sequence(), scope: db.instance_id() });
|
|
460
|
+
return;
|
|
461
|
+
}
|
|
462
|
+
if (request.method === 'POST' && pathname === '/query') {
|
|
463
|
+
const query = parseObject(await readBody(request));
|
|
464
|
+
if (namesPrivateCollection([query.collection])) {
|
|
465
|
+
refusePrivate(response);
|
|
466
|
+
return;
|
|
467
|
+
}
|
|
468
|
+
try {
|
|
469
|
+
json(response, 200, await db.authority_query(query));
|
|
470
|
+
}
|
|
471
|
+
catch (error) {
|
|
472
|
+
const code = error.code;
|
|
473
|
+
json(response, 400, { code, error: errorMessage(error), message: errorMessage(error) });
|
|
474
|
+
}
|
|
475
|
+
return;
|
|
476
|
+
}
|
|
477
|
+
if (request.method === 'POST' && pathname === '/transactions/read-basis') {
|
|
478
|
+
const reads = parseObject(await readBody(request));
|
|
479
|
+
if (namesPrivateCollection(readSetCollections(reads))) {
|
|
480
|
+
refusePrivate(response);
|
|
481
|
+
return;
|
|
482
|
+
}
|
|
483
|
+
json(response, 200, await db.read_basis(reads));
|
|
484
|
+
return;
|
|
485
|
+
}
|
|
486
|
+
if (request.method === 'POST' && pathname === '/transactions') {
|
|
487
|
+
const transaction = parseObject(await readBody(request));
|
|
488
|
+
if (namesPrivateCollection([
|
|
489
|
+
...(transaction.operations ?? []).map((operation) => operation?.collection),
|
|
490
|
+
...(transaction.preconditions ?? []).map((condition) => condition?.collection),
|
|
491
|
+
...(transaction.fences ?? []).flatMap((fence) => readSetCollections(fence?.reads)),
|
|
492
|
+
])) {
|
|
493
|
+
refusePrivate(response);
|
|
494
|
+
return;
|
|
495
|
+
}
|
|
496
|
+
let result;
|
|
497
|
+
try {
|
|
498
|
+
result = await db.commit_transaction(transaction);
|
|
499
|
+
}
|
|
500
|
+
catch (error) {
|
|
501
|
+
json(response, transactionRefusalStatus(error), transactionRefusal(error));
|
|
502
|
+
return;
|
|
503
|
+
}
|
|
504
|
+
json(response, 200, { ...result, status: 'committed' });
|
|
505
|
+
if (!result.duplicate) {
|
|
506
|
+
const collections = new Set((transaction.operations ?? []).map((operation) => operation.collection));
|
|
507
|
+
for (const collection of collections)
|
|
508
|
+
notify(collection);
|
|
509
|
+
}
|
|
510
|
+
return;
|
|
511
|
+
}
|
|
512
|
+
const casMatch = pathname.match(/^\/collections\/([^/]+)\/([^/]+)\/cas$/);
|
|
513
|
+
if (request.method === 'POST' && casMatch) {
|
|
514
|
+
const collection = decodeURIComponent(casMatch[1]);
|
|
515
|
+
if (namesPrivateCollection([collection])) {
|
|
516
|
+
refusePrivate(response);
|
|
517
|
+
return;
|
|
518
|
+
}
|
|
519
|
+
const input = parseObject(await readBody(request));
|
|
520
|
+
const result = await db.cas({
|
|
521
|
+
key: `${collection}:${decodeURIComponent(casMatch[2])}`,
|
|
522
|
+
expectedVersion: Number(input.expectedVersion),
|
|
523
|
+
expectedEpoch: input.expectedEpoch,
|
|
524
|
+
expectedLeaseId: input.expectedLeaseId,
|
|
525
|
+
rejectIfDiverged: input.rejectIfDiverged,
|
|
526
|
+
value: JSON.stringify(input.value ?? {}),
|
|
527
|
+
});
|
|
528
|
+
json(response, 200, result);
|
|
529
|
+
if (result.updated)
|
|
530
|
+
notify(collection);
|
|
531
|
+
return;
|
|
532
|
+
}
|
|
352
533
|
const match = pathname.match(/^\/collections\/([^/]+)(?:\/([^/]+))?$/);
|
|
353
534
|
if (!match) {
|
|
354
535
|
json(response, 404, { error: 'NOT_FOUND' });
|
|
355
536
|
return;
|
|
356
537
|
}
|
|
357
538
|
const collection = decodeURIComponent(match[1]);
|
|
358
|
-
if (collection
|
|
359
|
-
|
|
539
|
+
if (namesPrivateCollection([collection])) {
|
|
540
|
+
refusePrivate(response);
|
|
360
541
|
return;
|
|
361
542
|
}
|
|
362
543
|
const id = match[2] ? decodeURIComponent(match[2]) : undefined;
|
|
@@ -406,9 +587,7 @@ export async function startLocalDevelopmentAuthority(options) {
|
|
|
406
587
|
return;
|
|
407
588
|
}
|
|
408
589
|
json(response, request.method === 'POST' ? 201 : 200, { ok: true });
|
|
409
|
-
|
|
410
|
-
for (const stream of streams)
|
|
411
|
-
stream.write(frame);
|
|
590
|
+
notify(collection);
|
|
412
591
|
}
|
|
413
592
|
catch (error) {
|
|
414
593
|
if (!response.headersSent)
|
|
@@ -440,11 +619,152 @@ function json(response, status, value) {
|
|
|
440
619
|
response.setHeader('Content-Type', 'application/json; charset=utf-8');
|
|
441
620
|
response.end(JSON.stringify(value));
|
|
442
621
|
}
|
|
622
|
+
const IDENTITIES = '_feltdb.development_identities';
|
|
623
|
+
const SESSIONS = '_feltdb.development_sessions';
|
|
624
|
+
const SESSION_TTL_MS = 15 * 60_000;
|
|
625
|
+
function developmentAuth(db, operation, body, bearer) {
|
|
626
|
+
const read = (key) => {
|
|
627
|
+
const result = db.get(key);
|
|
628
|
+
if (!result.success)
|
|
629
|
+
throw new Error(result.error);
|
|
630
|
+
return result.data ? JSON.parse(result.data) : undefined;
|
|
631
|
+
};
|
|
632
|
+
const tokenHash = (token) => createHash('sha256').update(token).digest('hex');
|
|
633
|
+
const passwordHash = (password, salt) => scryptSync(password, salt, 32).toString('hex');
|
|
634
|
+
const refuse = (status, code, message) => ({ status, body: { code, error: code, message } });
|
|
635
|
+
const issue = (identity) => {
|
|
636
|
+
const token = randomBytes(32).toString('hex');
|
|
637
|
+
const session = { session_id: `session_${randomBytes(12).toString('hex')}`, expires_at: Date.now() + SESSION_TTL_MS };
|
|
638
|
+
const stored = db.insert(`${SESSIONS}:${tokenHash(token)}`, JSON.stringify({ ...session, user_id: identity.id }));
|
|
639
|
+
if (!stored.success)
|
|
640
|
+
throw new Error(stored.error);
|
|
641
|
+
return { status: 200, body: { access_token: token, actor: { user_id: identity.id, email: identity.email, display_name: identity.display_name }, session } };
|
|
642
|
+
};
|
|
643
|
+
if (operation === 'signout') {
|
|
644
|
+
if (bearer)
|
|
645
|
+
db.delete(`${SESSIONS}:${tokenHash(bearer)}`);
|
|
646
|
+
return { status: 200, body: {} };
|
|
647
|
+
}
|
|
648
|
+
if (operation === 'session') {
|
|
649
|
+
const sessionKey = `${SESSIONS}:${tokenHash(bearer)}`;
|
|
650
|
+
const session = bearer ? read(sessionKey) : undefined;
|
|
651
|
+
// An expired session is dead; remove it rather than keep it forever.
|
|
652
|
+
if (session && session.expires_at <= Date.now())
|
|
653
|
+
db.delete(sessionKey);
|
|
654
|
+
const identity = session && session.expires_at > Date.now()
|
|
655
|
+
? read(`${IDENTITIES}:${session.user_id}`)
|
|
656
|
+
: undefined;
|
|
657
|
+
if (!session || !identity)
|
|
658
|
+
return refuse(401, 'AUTHENTICATION_REQUIRED', 'Authentication required');
|
|
659
|
+
return { status: 200, body: { access_token: bearer, actor: { user_id: identity.id, email: identity.email, display_name: identity.display_name }, session: { session_id: session.session_id, expires_at: session.expires_at } } };
|
|
660
|
+
}
|
|
661
|
+
const email = String(body.email ?? '').trim().toLowerCase();
|
|
662
|
+
const password = String(body.password ?? '');
|
|
663
|
+
if (!email || password.length < 8)
|
|
664
|
+
return refuse(422, 'INVALID_CREDENTIALS_FORMAT', 'email and a password of at least 8 characters are required');
|
|
665
|
+
const byEmail = `${IDENTITIES}:email:${createHash('sha256').update(email).digest('hex')}`;
|
|
666
|
+
const existingId = read(byEmail)?.id;
|
|
667
|
+
if (operation === 'signup') {
|
|
668
|
+
if (existingId)
|
|
669
|
+
return refuse(409, 'CONFLICT', 'identity already exists');
|
|
670
|
+
const salt = randomBytes(16).toString('hex');
|
|
671
|
+
const identity = { id: `user_${randomBytes(12).toString('hex')}`, email, display_name: typeof body.display_name === 'string' ? body.display_name : undefined, salt, password_hash: passwordHash(password, salt), created_at: new Date().toISOString() };
|
|
672
|
+
const claimed = db.insert(byEmail, JSON.stringify({ id: identity.id }));
|
|
673
|
+
if (!claimed.success)
|
|
674
|
+
return refuse(409, 'CONFLICT', 'identity already exists');
|
|
675
|
+
const stored = db.insert(`${IDENTITIES}:${identity.id}`, JSON.stringify(identity));
|
|
676
|
+
if (!stored.success) {
|
|
677
|
+
// Do not leave the email claimed by an identity that was never stored.
|
|
678
|
+
db.delete(byEmail);
|
|
679
|
+
throw new Error(stored.error);
|
|
680
|
+
}
|
|
681
|
+
return issue(identity);
|
|
682
|
+
}
|
|
683
|
+
const identity = existingId ? read(`${IDENTITIES}:${existingId}`) : undefined;
|
|
684
|
+
const presented = Buffer.from(passwordHash(password, identity?.salt ?? 'no-identity'), 'hex');
|
|
685
|
+
const expected = Buffer.from(identity?.password_hash ?? '0'.repeat(64), 'hex');
|
|
686
|
+
if (!identity || presented.length !== expected.length || !timingSafeEqual(presented, expected)) {
|
|
687
|
+
return refuse(401, 'INVALID_CREDENTIALS', 'invalid credentials');
|
|
688
|
+
}
|
|
689
|
+
return issue(identity);
|
|
690
|
+
}
|
|
691
|
+
/**
|
|
692
|
+
* `_feltdb.*` collections are the authority's own records — development
|
|
693
|
+
* identities and sessions among them — and no client route reads or writes
|
|
694
|
+
* them: not `/collections`, not a query, not a transaction or its fences.
|
|
695
|
+
*/
|
|
696
|
+
function namesPrivateCollection(collections) {
|
|
697
|
+
// Coerced the way the transaction and query engines coerce a collection
|
|
698
|
+
// name, so a value that is not a string (`["_feltdb.x"]` stringifies to the
|
|
699
|
+
// private name) is judged by what it will be read as, not by its type.
|
|
700
|
+
return collections.some(collection => collection !== undefined && collection !== null
|
|
701
|
+
&& String(collection).trim().toLowerCase().startsWith('_feltdb.'));
|
|
702
|
+
}
|
|
703
|
+
function loopbackHost(host) {
|
|
704
|
+
if (!host)
|
|
705
|
+
return true;
|
|
706
|
+
try {
|
|
707
|
+
return LOOPBACK_HOSTNAMES.has(new URL(`http://${host}`).hostname);
|
|
708
|
+
}
|
|
709
|
+
catch {
|
|
710
|
+
return false;
|
|
711
|
+
}
|
|
712
|
+
}
|
|
713
|
+
function loopbackOrigin(origin) {
|
|
714
|
+
if (!origin)
|
|
715
|
+
return false;
|
|
716
|
+
try {
|
|
717
|
+
return LOOPBACK_HOSTNAMES.has(new URL(origin).hostname);
|
|
718
|
+
}
|
|
719
|
+
catch {
|
|
720
|
+
return false;
|
|
721
|
+
}
|
|
722
|
+
}
|
|
723
|
+
const LOOPBACK_HOSTNAMES = new Set(['localhost', '127.0.0.1', '[::1]']);
|
|
724
|
+
function readSetCollections(reads) {
|
|
725
|
+
const set = (reads ?? {});
|
|
726
|
+
return [...(set.records ?? []), ...(set.predicates ?? [])].map(entry => entry?.collection);
|
|
727
|
+
}
|
|
728
|
+
function refusePrivate(response) {
|
|
729
|
+
json(response, 403, { error: 'SYSTEM_COLLECTION_REQUIRES_SERVICE_API' });
|
|
730
|
+
}
|
|
731
|
+
function errorMessage(error) {
|
|
732
|
+
return error instanceof Error ? error.message : String(error);
|
|
733
|
+
}
|
|
734
|
+
/**
|
|
735
|
+
* A refused transaction, in the document feltdb-server answers with: a lost
|
|
736
|
+
* conditional race is `409 PRECONDITION_FAILED` with the failed predicate, a
|
|
737
|
+
* reused transaction id is `409` with its idempotency code, and a malformed
|
|
738
|
+
* request is `400`. Callers type the refusal from `code`, never from prose.
|
|
739
|
+
*/
|
|
740
|
+
function transactionRefusal(error) {
|
|
741
|
+
const refusal = error;
|
|
742
|
+
return {
|
|
743
|
+
error: errorMessage(error),
|
|
744
|
+
code: refusal.feltdbCode ?? refusal.code,
|
|
745
|
+
...(refusal.feltdbConflict ? { failure: refusal.feltdbConflict } : {}),
|
|
746
|
+
};
|
|
747
|
+
}
|
|
748
|
+
function transactionRefusalStatus(error) {
|
|
749
|
+
const refusal = error;
|
|
750
|
+
if (refusal.feltdbCode === 'PRECONDITION_FAILED')
|
|
751
|
+
return 409;
|
|
752
|
+
if (refusal.code === 'IDEMPOTENCY_CONFLICT' || refusal.code === 'IDEMPOTENCY_UNVERIFIABLE')
|
|
753
|
+
return 409;
|
|
754
|
+
return 400;
|
|
755
|
+
}
|
|
756
|
+
const MAX_BODY_BYTES = 1024 * 1024;
|
|
443
757
|
function readBody(request) {
|
|
444
758
|
return new Promise((resolve, reject) => {
|
|
445
759
|
let body = '';
|
|
446
760
|
request.setEncoding('utf8');
|
|
447
|
-
request.on('data', chunk => {
|
|
761
|
+
request.on('data', chunk => {
|
|
762
|
+
body += chunk;
|
|
763
|
+
if (body.length > MAX_BODY_BYTES) {
|
|
764
|
+
request.destroy();
|
|
765
|
+
reject(new Error('Request body is too large'));
|
|
766
|
+
}
|
|
767
|
+
});
|
|
448
768
|
request.once('end', () => resolve(body));
|
|
449
769
|
request.once('error', reject);
|
|
450
770
|
});
|
package/package.json
CHANGED
|
Binary file
|