@polderlabs/bizar 6.3.0 → 7.0.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/.claude/hooks/sessionend-recall.mjs +3 -3
- package/.claude/hooks/worker-suggest.mjs +110 -0
- package/.claude/settings.json +9 -0
- package/bizar-dash/dist/assets/{EnvVarsSection-B58aiJiE.js → EnvVarsSection-CW6fWKds.js} +3 -3
- package/bizar-dash/dist/assets/{EnvVarsSection-B58aiJiE.js.map → EnvVarsSection-CW6fWKds.js.map} +1 -1
- package/bizar-dash/dist/assets/MobileChat-D7zRhU9K.js +1 -0
- package/bizar-dash/dist/assets/{MobileChat-BJrqwVDd.js.map → MobileChat-D7zRhU9K.js.map} +1 -1
- package/bizar-dash/dist/assets/MobileSettings-K-sNsn_r.js +1 -0
- package/bizar-dash/dist/assets/{MobileSettings-CEQNJNLJ.js.map → MobileSettings-K-sNsn_r.js.map} +1 -1
- package/bizar-dash/dist/assets/{Toast-Cpl6-E63.js → Toast-WTxc3m2g.js} +1 -1
- package/bizar-dash/dist/assets/{Toast-Cpl6-E63.js.map → Toast-WTxc3m2g.js.map} +1 -1
- package/bizar-dash/dist/assets/flow-BpU0-gK2.js +1 -0
- package/bizar-dash/dist/assets/flow-BpU0-gK2.js.map +1 -0
- package/bizar-dash/dist/assets/fuzzy-l0sNRNKZ.js +1 -0
- package/bizar-dash/dist/assets/fuzzy-l0sNRNKZ.js.map +1 -0
- package/bizar-dash/dist/assets/{icons-Do5N2jmX.js → icons-B0AKnYI3.js} +98 -103
- package/bizar-dash/dist/assets/icons-B0AKnYI3.js.map +1 -0
- package/bizar-dash/dist/assets/main-C-Md3DaY.css +1 -0
- package/bizar-dash/dist/assets/main-CO8fIxQK.js +18 -0
- package/bizar-dash/dist/assets/main-CO8fIxQK.js.map +1 -0
- package/bizar-dash/dist/assets/markdown-w2bDDkAH.js +1 -0
- package/bizar-dash/dist/assets/{markdown-tOLaD6nm.js.map → markdown-w2bDDkAH.js.map} +1 -1
- package/bizar-dash/dist/assets/mobile-DXYCA0hC.js +1 -0
- package/bizar-dash/dist/assets/{mobile-DYCHcUpq.js.map → mobile-DXYCA0hC.js.map} +1 -1
- package/bizar-dash/dist/assets/mobile-layout-DSmtNPja.js +2 -0
- package/bizar-dash/dist/assets/mobile-layout-DSmtNPja.js.map +1 -0
- package/bizar-dash/dist/assets/react-vendor-BnuhLJ6X.css +1 -0
- package/bizar-dash/dist/assets/react-vendor-D3pra6w-.js +46 -0
- package/bizar-dash/dist/assets/react-vendor-D3pra6w-.js.map +1 -0
- package/bizar-dash/dist/assets/useSlashCommands-3DFLOfpk.js +2 -0
- package/bizar-dash/dist/assets/useSlashCommands-3DFLOfpk.js.map +1 -0
- package/bizar-dash/dist/assets/vendor-Di7Cp-rw.js +45 -0
- package/bizar-dash/dist/assets/vendor-Di7Cp-rw.js.map +1 -0
- package/bizar-dash/dist/index.html +12 -10
- package/bizar-dash/dist/mobile.html +7 -4
- package/bizar-dash/node_modules/.vite/vitest/da39a3ee5e6b4b0d3255bfef95601890afd80709/results.json +1 -1
- package/bizar-dash/src/server/api.mjs +11 -0
- package/bizar-dash/src/server/memory-consolidator.mjs +218 -0
- package/bizar-dash/src/server/memory-distillation.mjs +296 -0
- package/bizar-dash/src/server/routes/distill.mjs +129 -0
- package/bizar-dash/src/server/routes/goal-planner.mjs +236 -0
- package/bizar-dash/src/web/App.tsx +4 -0
- package/bizar-dash/src/web/components/Tag.tsx +18 -5
- package/bizar-dash/src/web/components/Topbar.tsx +3 -0
- package/bizar-dash/src/web/components/agents/CommunicationLog.tsx +126 -0
- package/bizar-dash/src/web/components/agents/DependencyGraph.tsx +163 -0
- package/bizar-dash/src/web/components/agents/QualityGates.tsx +123 -0
- package/bizar-dash/src/web/components/agents/RealTimeEventLog.tsx +127 -0
- package/bizar-dash/src/web/components/agents/RoutingDecisions.test.tsx +148 -0
- package/bizar-dash/src/web/components/agents/RoutingDecisions.tsx +160 -0
- package/bizar-dash/src/web/components/goals/GoalInput.tsx +86 -0
- package/bizar-dash/src/web/components/goals/PlanVisualization.tsx +111 -0
- package/bizar-dash/src/web/lib/goapPlanner.ts +614 -0
- package/bizar-dash/src/web/lib/types.ts +25 -0
- package/bizar-dash/src/web/styles/agents-redesign.css +311 -0
- package/bizar-dash/src/web/styles/main.css +251 -0
- package/bizar-dash/src/web/styles/tasks-redesign.css +231 -0
- package/bizar-dash/src/web/ui/controls/Button.tsx +77 -0
- package/bizar-dash/src/web/ui/controls/Checkbox.tsx +95 -0
- package/bizar-dash/src/web/ui/controls/IconButton.tsx +66 -0
- package/bizar-dash/src/web/ui/controls/Kbd.tsx +24 -0
- package/bizar-dash/src/web/ui/controls/NumberInput.tsx +77 -0
- package/bizar-dash/src/web/ui/controls/RadioGroup.tsx +109 -0
- package/bizar-dash/src/web/ui/controls/SearchInput.tsx +91 -0
- package/bizar-dash/src/web/ui/controls/Select.tsx +92 -0
- package/bizar-dash/src/web/ui/controls/Slider.tsx +70 -0
- package/bizar-dash/src/web/ui/controls/TextInput.tsx +67 -0
- package/bizar-dash/src/web/ui/controls/Toggle.tsx +79 -0
- package/bizar-dash/src/web/ui/controls/controls.css +454 -0
- package/bizar-dash/src/web/ui/controls/index.ts +33 -0
- package/bizar-dash/src/web/ui/data/BarChart.tsx +60 -0
- package/bizar-dash/src/web/ui/data/DataTable.tsx +216 -0
- package/bizar-dash/src/web/ui/data/EmptyState.tsx +60 -0
- package/bizar-dash/src/web/ui/data/ErrorState.tsx +56 -0
- package/bizar-dash/src/web/ui/data/KeyValueList.tsx +106 -0
- package/bizar-dash/src/web/ui/data/LoadingState.tsx +65 -0
- package/bizar-dash/src/web/ui/data/Sparkline.tsx +87 -0
- package/bizar-dash/src/web/ui/data/StatTile.tsx +87 -0
- package/bizar-dash/src/web/ui/data/data.css +535 -0
- package/bizar-dash/src/web/ui/data/index.ts +31 -0
- package/bizar-dash/src/web/ui/feedback/Badge.tsx +49 -0
- package/bizar-dash/src/web/ui/feedback/Dialog.tsx +209 -0
- package/bizar-dash/src/web/ui/feedback/ProgressBar.tsx +69 -0
- package/bizar-dash/src/web/ui/feedback/StatusDot.tsx +50 -0
- package/bizar-dash/src/web/ui/feedback/Toast.tsx +200 -0
- package/bizar-dash/src/web/ui/feedback/Tooltip.tsx +126 -0
- package/bizar-dash/src/web/ui/feedback/feedback.css +314 -0
- package/bizar-dash/src/web/ui/feedback/index.ts +43 -0
- package/bizar-dash/src/web/ui/index.ts +56 -0
- package/bizar-dash/src/web/ui/layout/AppShell.tsx +51 -0
- package/bizar-dash/src/web/ui/layout/Breadcrumbs.tsx +97 -0
- package/bizar-dash/src/web/ui/layout/Panel.tsx +73 -0
- package/bizar-dash/src/web/ui/layout/PanelHeader.tsx +39 -0
- package/bizar-dash/src/web/ui/layout/Sidebar.tsx +131 -0
- package/bizar-dash/src/web/ui/layout/Tabs.tsx +133 -0
- package/bizar-dash/src/web/ui/layout/Topbar.tsx +42 -0
- package/bizar-dash/src/web/ui/layout/index.ts +33 -0
- package/bizar-dash/src/web/ui/layout/layout.css +413 -0
- package/bizar-dash/src/web/ui/navigation/CommandPalette.tsx +199 -0
- package/bizar-dash/src/web/ui/navigation/NavGroup.tsx +95 -0
- package/bizar-dash/src/web/ui/navigation/NavLink.tsx +93 -0
- package/bizar-dash/src/web/ui/navigation/index.ts +17 -0
- package/bizar-dash/src/web/ui/navigation/navigation.css +343 -0
- package/bizar-dash/src/web/ui/primitives/Box.tsx +91 -0
- package/bizar-dash/src/web/ui/primitives/Grid.tsx +49 -0
- package/bizar-dash/src/web/ui/primitives/Inline.tsx +55 -0
- package/bizar-dash/src/web/ui/primitives/Separator.tsx +46 -0
- package/bizar-dash/src/web/ui/primitives/Stack.tsx +54 -0
- package/bizar-dash/src/web/ui/primitives/VisuallyHidden.tsx +26 -0
- package/bizar-dash/src/web/ui/primitives/index.ts +23 -0
- package/bizar-dash/src/web/ui/primitives/primitives.css +183 -0
- package/bizar-dash/src/web/ui/styles/globals.css +86 -0
- package/bizar-dash/src/web/ui/styles/reset.css +125 -0
- package/bizar-dash/src/web/ui/styles/tokens.css +195 -0
- package/bizar-dash/src/web/ui/theme/ThemeProvider.tsx +142 -0
- package/bizar-dash/src/web/ui/theme/tokens.ts +131 -0
- package/bizar-dash/src/web/ui/theme/useTheme.ts +22 -0
- package/bizar-dash/src/web/ui/utils/cx.ts +42 -0
- package/bizar-dash/src/web/views/Agents.tsx +506 -266
- package/bizar-dash/src/web/views/GoalPlanner.tsx +158 -0
- package/bizar-dash/src/web/views/Overview.tsx +996 -448
- package/bizar-dash/src/web/views/Tasks.tsx +277 -273
- package/bizar-dash/tests/communication-log.test.tsx +67 -0
- package/bizar-dash/tests/dep-graph.test.tsx +40 -0
- package/bizar-dash/tests/event-log.test.tsx +61 -0
- package/bizar-dash/tests/goal-input.test.tsx +72 -0
- package/bizar-dash/tests/goap-planner.test.ts +68 -0
- package/bizar-dash/tests/quality-gates.test.tsx +59 -0
- package/bizar-dash/tests/setup.ts +31 -1
- package/bizar-dash/tests/ui/controls/Button.test.tsx +50 -0
- package/bizar-dash/tests/ui/controls/Checkbox.test.tsx +44 -0
- package/bizar-dash/tests/ui/controls/IconButton.test.tsx +37 -0
- package/bizar-dash/tests/ui/controls/Kbd.test.tsx +39 -0
- package/bizar-dash/tests/ui/controls/NumberInput.test.tsx +56 -0
- package/bizar-dash/tests/ui/controls/RadioGroup.test.tsx +58 -0
- package/bizar-dash/tests/ui/controls/SearchInput.test.tsx +52 -0
- package/bizar-dash/tests/ui/controls/Select.test.tsx +66 -0
- package/bizar-dash/tests/ui/controls/Slider.test.tsx +41 -0
- package/bizar-dash/tests/ui/controls/TextInput.test.tsx +44 -0
- package/bizar-dash/tests/ui/controls/Toggle.test.tsx +39 -0
- package/bizar-dash/tests/ui/data/BarChart.test.tsx +55 -0
- package/bizar-dash/tests/ui/data/DataTable.test.tsx +112 -0
- package/bizar-dash/tests/ui/data/EmptyState.test.tsx +57 -0
- package/bizar-dash/tests/ui/data/ErrorState.test.tsx +47 -0
- package/bizar-dash/tests/ui/data/KeyValueList.test.tsx +59 -0
- package/bizar-dash/tests/ui/data/LoadingState.test.tsx +37 -0
- package/bizar-dash/tests/ui/data/Sparkline.test.tsx +56 -0
- package/bizar-dash/tests/ui/data/StatTile.test.tsx +69 -0
- package/bizar-dash/tests/ui/feedback/Badge.test.tsx +41 -0
- package/bizar-dash/tests/ui/feedback/Dialog.test.tsx +99 -0
- package/bizar-dash/tests/ui/feedback/ProgressBar.test.tsx +66 -0
- package/bizar-dash/tests/ui/feedback/StatusDot.test.tsx +41 -0
- package/bizar-dash/tests/ui/feedback/Toast.test.tsx +124 -0
- package/bizar-dash/tests/ui/feedback/Tooltip.test.tsx +102 -0
- package/bizar-dash/tests/ui/layout/AppShell.test.tsx +50 -0
- package/bizar-dash/tests/ui/layout/Breadcrumbs.test.tsx +85 -0
- package/bizar-dash/tests/ui/layout/Panel.test.tsx +52 -0
- package/bizar-dash/tests/ui/layout/PanelHeader.test.tsx +32 -0
- package/bizar-dash/tests/ui/layout/Sidebar.test.tsx +95 -0
- package/bizar-dash/tests/ui/layout/Tabs.test.tsx +87 -0
- package/bizar-dash/tests/ui/layout/Topbar.test.tsx +41 -0
- package/bizar-dash/tests/ui/navigation/CommandPalette.test.tsx +105 -0
- package/bizar-dash/tests/ui/navigation/NavGroup.test.tsx +55 -0
- package/bizar-dash/tests/ui/navigation/NavLink.test.tsx +61 -0
- package/bizar-dash/tests/ui/primitives/Box.test.tsx +49 -0
- package/bizar-dash/tests/ui/primitives/Grid.test.tsx +37 -0
- package/bizar-dash/tests/ui/primitives/Inline.test.tsx +33 -0
- package/bizar-dash/tests/ui/primitives/Separator.test.tsx +34 -0
- package/bizar-dash/tests/ui/primitives/Stack.test.tsx +45 -0
- package/bizar-dash/tests/ui/primitives/VisuallyHidden.test.tsx +27 -0
- package/bizar-dash/tests/views/Agents.test.tsx +359 -0
- package/bizar-dash/tests/views/Overview.test.tsx +413 -0
- package/bizar-dash/tests/views/Tasks.test.tsx +250 -0
- package/bizar-dash/vitest.config.ts +3 -1
- package/cli/__tests__/cost-gate.test.mjs +301 -0
- package/cli/__tests__/feature-list-bridge.test.mjs +371 -0
- package/cli/bin.mjs +38 -0
- package/cli/commands/claim.mjs +282 -0
- package/cli/commands/cost.mjs +279 -0
- package/cli/cost-gate.mjs +501 -0
- package/cli/feature-list-bridge.mjs +400 -0
- package/cli/memory-constants.mjs +17 -0
- package/cli/worker-dispatcher.mjs +259 -0
- package/cli/worker-dispatcher.test.mjs +204 -0
- package/config/trigger-patterns.json +186 -0
- package/package.json +2 -2
- package/packages/sdk/package.json +1 -1
- package/packages/sdk/src/agent-registry.ts +304 -0
- package/packages/sdk/src/consensus/byzantine.ts +587 -0
- package/packages/sdk/src/consensus/index.ts +147 -0
- package/packages/sdk/src/consensus/queen.ts +173 -0
- package/packages/sdk/src/consensus/types.ts +199 -0
- package/packages/sdk/src/federation/audit.ts +156 -0
- package/packages/sdk/src/federation/budget.ts +269 -0
- package/packages/sdk/src/federation/envelope.ts +144 -0
- package/packages/sdk/src/federation/hmac.ts +160 -0
- package/packages/sdk/src/federation/index.ts +443 -0
- package/packages/sdk/src/federation/pii.ts +241 -0
- package/packages/sdk/src/federation/policy.ts +112 -0
- package/packages/sdk/src/federation/trust.ts +146 -0
- package/packages/sdk/src/index.ts +67 -0
- package/packages/sdk/src/mcp/server.ts +351 -1
- package/packages/sdk/src/router/codemod-intent.ts +125 -0
- package/packages/sdk/src/router/index.ts +247 -0
- package/packages/sdk/src/router/memory-distillation-shim.mjs +18 -0
- package/packages/sdk/src/router/memory-distillation.ts +249 -0
- package/packages/sdk/src/router/model-router.ts +235 -0
- package/packages/sdk/src/router/q-learning-router.ts +304 -0
- package/packages/sdk/src/swarm-topology.ts +279 -0
- package/packages/sdk/tests/agent-registry.test.ts +285 -0
- package/packages/sdk/tests/consensus.test.ts +376 -0
- package/packages/sdk/tests/federation/audit.test.ts +164 -0
- package/packages/sdk/tests/federation/budget.test.ts +189 -0
- package/packages/sdk/tests/federation/envelope.test.ts +120 -0
- package/packages/sdk/tests/federation/hmac.test.ts +194 -0
- package/packages/sdk/tests/federation/orchestrator.test.ts +176 -0
- package/packages/sdk/tests/federation/pii.test.ts +106 -0
- package/packages/sdk/tests/federation/policy.test.ts +140 -0
- package/packages/sdk/tests/federation/trust.test.ts +124 -0
- package/packages/sdk/tests/mcp-tools.test.ts +204 -0
- package/packages/sdk/tests/memory-distillation.test.mjs +186 -0
- package/packages/sdk/tests/model-router.test.mjs +108 -0
- package/packages/sdk/tests/q-learning-router.test.mjs +100 -0
- package/packages/sdk/tests/router-orchestrator.test.mjs +172 -0
- package/packages/sdk/tests/router.test.mjs +91 -0
- package/packages/sdk/tests/sdk.test.mjs +26 -2
- package/packages/sdk/tests/swarm-topology.test.ts +258 -0
- package/scripts/bh-full-e2e.mjs +11 -15
- package/bizar-dash/dist/assets/MobileChat-BJrqwVDd.js +0 -1
- package/bizar-dash/dist/assets/MobileSettings-CEQNJNLJ.js +0 -1
- package/bizar-dash/dist/assets/icons-Do5N2jmX.js.map +0 -1
- package/bizar-dash/dist/assets/main-IvfQAOfy.js +0 -18
- package/bizar-dash/dist/assets/main-IvfQAOfy.js.map +0 -1
- package/bizar-dash/dist/assets/main-ietCEg_R.css +0 -1
- package/bizar-dash/dist/assets/markdown-tOLaD6nm.js +0 -1
- package/bizar-dash/dist/assets/mobile-DYCHcUpq.js +0 -1
- package/bizar-dash/dist/assets/mobile-layout-CBHjpwsb.js +0 -2
- package/bizar-dash/dist/assets/mobile-layout-CBHjpwsb.js.map +0 -1
- package/bizar-dash/dist/assets/react-vendor-Dn4wqh4Z.js +0 -40
- package/bizar-dash/dist/assets/react-vendor-Dn4wqh4Z.js.map +0 -1
- package/bizar-dash/dist/assets/useSlashCommands-Bd7_FA6U.js +0 -2
- package/bizar-dash/dist/assets/useSlashCommands-Bd7_FA6U.js.map +0 -1
- package/bizar-dash/dist/assets/vendor-C843201K.js +0 -29
- package/bizar-dash/dist/assets/vendor-C843201K.js.map +0 -1
- package/packages/sdk/package-lock.json +0 -32
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* federation/budget.test.ts — F-038 FederationBudget tests.
|
|
3
|
+
*
|
|
4
|
+
* Covers:
|
|
5
|
+
* - reserve/commit/release state machine.
|
|
6
|
+
* - setLimits overrides per-peer limits.
|
|
7
|
+
* - getOrCreate lazily creates peer records.
|
|
8
|
+
* - validateBudgetInput rejects invalid inputs.
|
|
9
|
+
* - Persistence: saveToDisk + loadFromDisk round-trip.
|
|
10
|
+
* - outstanding() tracks un-released reservations.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { describe, test, expect, beforeEach, afterEach } from "vitest";
|
|
14
|
+
import { mkdtempSync, rmSync, existsSync, readFileSync, writeFileSync, mkdirSync } from "node:fs";
|
|
15
|
+
import { tmpdir } from "node:os";
|
|
16
|
+
import { join } from "node:path";
|
|
17
|
+
import {
|
|
18
|
+
DEFAULT_BUDGET_PATH,
|
|
19
|
+
FederationBudget,
|
|
20
|
+
MAX_TOKENS_CEILING,
|
|
21
|
+
MAX_USD_CEILING,
|
|
22
|
+
validateBudgetInput,
|
|
23
|
+
} from "../../src/federation/budget.js";
|
|
24
|
+
|
|
25
|
+
describe("federation/budget — validateBudgetInput", () => {
|
|
26
|
+
test("accepts null → defaults", () => {
|
|
27
|
+
const r = validateBudgetInput(null);
|
|
28
|
+
expect(r.ok).toBe(true);
|
|
29
|
+
if (r.ok) {
|
|
30
|
+
expect(r.maxHops).toBe(8);
|
|
31
|
+
expect(r.maxTokens).toBe(MAX_TOKENS_CEILING);
|
|
32
|
+
expect(r.maxUsd).toBe(MAX_USD_CEILING);
|
|
33
|
+
}
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
test("accepts a fully-specified budget", () => {
|
|
37
|
+
const r = validateBudgetInput({ maxTokens: 1000, maxUsd: 0.5, maxHops: 4 });
|
|
38
|
+
expect(r.ok).toBe(true);
|
|
39
|
+
if (r.ok) {
|
|
40
|
+
expect(r.maxTokens).toBe(1000);
|
|
41
|
+
expect(r.maxUsd).toBe(0.5);
|
|
42
|
+
expect(r.maxHops).toBe(4);
|
|
43
|
+
}
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
test("rejects non-object inputs", () => {
|
|
47
|
+
expect(validateBudgetInput(42).ok).toBe(false);
|
|
48
|
+
expect(validateBudgetInput("foo").ok).toBe(false);
|
|
49
|
+
expect(validateBudgetInput([]).ok).toBe(false);
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
test("rejects maxHops out of range", () => {
|
|
53
|
+
expect(validateBudgetInput({ maxHops: 100 }).ok).toBe(false);
|
|
54
|
+
expect(validateBudgetInput({ maxHops: -1 }).ok).toBe(false);
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
test("rejects maxUsd out of range", () => {
|
|
58
|
+
expect(validateBudgetInput({ maxUsd: -1 }).ok).toBe(false);
|
|
59
|
+
expect(validateBudgetInput({ maxUsd: MAX_USD_CEILING + 1 }).ok).toBe(false);
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
test("rejects maxTokens out of range", () => {
|
|
63
|
+
expect(validateBudgetInput({ maxTokens: -1 }).ok).toBe(false);
|
|
64
|
+
expect(validateBudgetInput({ maxTokens: MAX_TOKENS_CEILING + 1 }).ok).toBe(false);
|
|
65
|
+
});
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
describe("federation/budget — FederationBudget lifecycle", () => {
|
|
69
|
+
let dir: string;
|
|
70
|
+
let path: string;
|
|
71
|
+
let budget: FederationBudget;
|
|
72
|
+
|
|
73
|
+
beforeEach(() => {
|
|
74
|
+
dir = mkdtempSync(join(tmpdir(), "bizar-budget-"));
|
|
75
|
+
path = join(dir, "federation-budget.json");
|
|
76
|
+
budget = new FederationBudget({
|
|
77
|
+
path,
|
|
78
|
+
defaultMaxTokens: 10_000,
|
|
79
|
+
defaultMaxUsd: 5,
|
|
80
|
+
defaultMaxHops: 8,
|
|
81
|
+
});
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
afterEach(() => {
|
|
85
|
+
rmSync(dir, { recursive: true, force: true });
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
test("getOrCreate lazily creates a peer", () => {
|
|
89
|
+
const peer = budget.getOrCreate("node-A");
|
|
90
|
+
expect(peer.nodeId).toBe("node-A");
|
|
91
|
+
expect(peer.maxTokens).toBe(10_000);
|
|
92
|
+
expect(peer.maxUsd).toBe(5);
|
|
93
|
+
expect(peer.spentTokens).toBe(0);
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
test("reserve + commit round-trip debits spent then refunds the diff", () => {
|
|
97
|
+
const r = budget.reserve("node-A", 100, 0.5);
|
|
98
|
+
expect(r.ok).toBe(true);
|
|
99
|
+
if (!r.ok) return;
|
|
100
|
+
let peer = budget.getOrCreate("node-A");
|
|
101
|
+
expect(peer.spentTokens).toBe(100);
|
|
102
|
+
expect(peer.spentUsd).toBe(0.5);
|
|
103
|
+
// Commit reports actual was lower than reserved.
|
|
104
|
+
const ok = budget.commit(r.reservationId, { tokens: 60, usd: 0.2 });
|
|
105
|
+
expect(ok).toBe(true);
|
|
106
|
+
peer = budget.getOrCreate("node-A");
|
|
107
|
+
expect(peer.spentTokens).toBe(60);
|
|
108
|
+
expect(peer.spentUsd).toBe(0.2);
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
test("reserve + release round-trip refunds full reservation", () => {
|
|
112
|
+
const r = budget.reserve("node-A", 100, 0.5);
|
|
113
|
+
expect(r.ok).toBe(true);
|
|
114
|
+
if (!r.ok) return;
|
|
115
|
+
budget.release(r.reservationId);
|
|
116
|
+
const peer = budget.getOrCreate("node-A");
|
|
117
|
+
expect(peer.spentTokens).toBe(0);
|
|
118
|
+
expect(peer.spentUsd).toBe(0);
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
test("reserve over budget denies", () => {
|
|
122
|
+
budget.setLimits("node-A", { maxTokens: 50, maxUsd: 1 });
|
|
123
|
+
const r = budget.reserve("node-A", 100, 0.5);
|
|
124
|
+
expect(r.ok).toBe(false);
|
|
125
|
+
if (!r.ok) expect(r.reason).toBe("peer_over_budget");
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
test("reserve with invalid amount denies", () => {
|
|
129
|
+
const r = budget.reserve("node-A", Number.NaN, 0);
|
|
130
|
+
expect(r.ok).toBe(false);
|
|
131
|
+
if (!r.ok) expect(r.reason).toBe("invalid_amount");
|
|
132
|
+
|
|
133
|
+
const r2 = budget.reserve("node-A", -1, 0);
|
|
134
|
+
expect(r2.ok).toBe(false);
|
|
135
|
+
if (!r2.ok) expect(r2.reason).toBe("invalid_amount");
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
test("outstanding() tracks un-released reservations", () => {
|
|
139
|
+
const r = budget.reserve("node-A", 10, 0.1);
|
|
140
|
+
expect(budget.outstanding().length).toBe(1);
|
|
141
|
+
if (r.ok) budget.commit(r.reservationId);
|
|
142
|
+
expect(budget.outstanding().length).toBe(0);
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
test("commit unknown reservation returns false", () => {
|
|
146
|
+
expect(budget.commit("not-a-real-id")).toBe(false);
|
|
147
|
+
expect(budget.release("not-a-real-id")).toBe(false);
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
test("saveToDisk + loadFromDisk round-trip", () => {
|
|
151
|
+
budget.setLimits("node-A", { maxTokens: 999, maxUsd: 0.99, maxHops: 4 });
|
|
152
|
+
expect(existsSync(path)).toBe(true);
|
|
153
|
+
const fresh = new FederationBudget({ path });
|
|
154
|
+
const peer = fresh.getOrCreate("node-A");
|
|
155
|
+
expect(peer.maxTokens).toBe(999);
|
|
156
|
+
expect(peer.maxUsd).toBe(0.99);
|
|
157
|
+
expect(peer.maxHops).toBe(4);
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
test("snapshot returns every per-peer budget", () => {
|
|
161
|
+
budget.getOrCreate("node-A");
|
|
162
|
+
budget.getOrCreate("node-B");
|
|
163
|
+
const snap = budget.snapshot();
|
|
164
|
+
expect(snap.length).toBe(2);
|
|
165
|
+
expect(new Set(snap.map((p) => p.nodeId))).toEqual(new Set(["node-A", "node-B"]));
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
test("toJSON returns the documented shape", () => {
|
|
169
|
+
budget.setLimits("node-A", { maxTokens: 100, maxUsd: 0.5, maxHops: 2 });
|
|
170
|
+
const json = budget.toJSON();
|
|
171
|
+
expect(json.version).toBe(1);
|
|
172
|
+
expect(json.perPeer.length).toBe(1);
|
|
173
|
+
expect(json.perPeer[0].nodeId).toBe("node-A");
|
|
174
|
+
expect(typeof json.updatedAt).toBe("string");
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
test("DEFAULT_BUDGET_PATH is the harness-local path", () => {
|
|
178
|
+
expect(DEFAULT_BUDGET_PATH).toBe(".harness/federation-budget.json");
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
test("loadFromDisk is tolerant of malformed JSON", () => {
|
|
182
|
+
// Write garbage, then construct a new instance — it should
|
|
183
|
+
// not throw.
|
|
184
|
+
mkdirSync(dir, { recursive: true });
|
|
185
|
+
writeFileSync(path, "not json", "utf-8");
|
|
186
|
+
const fresh = new FederationBudget({ path });
|
|
187
|
+
expect(fresh.getOrCreate("node-A").maxTokens).toBe(MAX_TOKENS_CEILING);
|
|
188
|
+
});
|
|
189
|
+
});
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* federation/envelope.test.ts — F-038 envelope type tests.
|
|
3
|
+
*
|
|
4
|
+
* Covers:
|
|
5
|
+
* - The 16 FederationMessageType kinds round-trip through
|
|
6
|
+
* isFederationMessageType (positive + negative).
|
|
7
|
+
* - canonicalSignablePayload is deterministic + field-order stable.
|
|
8
|
+
* - envelopeAgeMs clamps correctly for fresh + old + future.
|
|
9
|
+
* - emptyScanResult returns the documented zero shape.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { describe, test, expect } from "vitest";
|
|
13
|
+
import {
|
|
14
|
+
FEDERATION_MESSAGE_TYPES,
|
|
15
|
+
TRUST_AFFECTING_MESSAGE_TYPES,
|
|
16
|
+
canonicalSignablePayload,
|
|
17
|
+
emptyScanResult,
|
|
18
|
+
envelopeAgeMs,
|
|
19
|
+
isFederationMessageType,
|
|
20
|
+
type FederationEnvelope,
|
|
21
|
+
} from "../../src/federation/envelope.js";
|
|
22
|
+
|
|
23
|
+
describe("federation/envelope — message kinds", () => {
|
|
24
|
+
test("FEDERATION_MESSAGE_TYPES has 17 entries (16 ruflo kinds + claim-event)", () => {
|
|
25
|
+
expect(FEDERATION_MESSAGE_TYPES.length).toBe(17);
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
test("each declared kind passes isFederationMessageType", () => {
|
|
29
|
+
for (const k of FEDERATION_MESSAGE_TYPES) {
|
|
30
|
+
expect(isFederationMessageType(k)).toBe(true);
|
|
31
|
+
}
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
test("isFederationMessageType rejects unknown / mistyped values", () => {
|
|
35
|
+
expect(isFederationMessageType("not-a-kind")).toBe(false);
|
|
36
|
+
expect(isFederationMessageType(null)).toBe(false);
|
|
37
|
+
expect(isFederationMessageType(undefined)).toBe(false);
|
|
38
|
+
expect(isFederationMessageType(42)).toBe(false);
|
|
39
|
+
expect(isFederationMessageType({})).toBe(false);
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
test("TRUST_AFFECTING_MESSAGE_TYPES contains the 4 risk kinds", () => {
|
|
43
|
+
expect(["trust-change", "topology-change", "agent-spawn", "agent-handoff"])
|
|
44
|
+
.toEqual(expect.arrayContaining([...TRUST_AFFECTING_MESSAGE_TYPES]));
|
|
45
|
+
});
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
describe("federation/envelope — canonical payload", () => {
|
|
49
|
+
const sample: FederationEnvelope = {
|
|
50
|
+
envelopeId: "env-1",
|
|
51
|
+
sourceNodeId: "node-A",
|
|
52
|
+
targetNodeId: "node-B",
|
|
53
|
+
sessionId: "sess-1",
|
|
54
|
+
messageType: "heartbeat",
|
|
55
|
+
payload: { ok: true },
|
|
56
|
+
timestamp: "2026-07-12T00:00:00.000Z",
|
|
57
|
+
nonce: "nonce-1",
|
|
58
|
+
hmacSignature: "sig-1",
|
|
59
|
+
piiScanResult: emptyScanResult(),
|
|
60
|
+
hopCount: 0,
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
test("canonicalSignablePayload is deterministic", () => {
|
|
64
|
+
const a = canonicalSignablePayload(sample);
|
|
65
|
+
const b = canonicalSignablePayload(sample);
|
|
66
|
+
expect(a).toBe(b);
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
test("canonicalSignablePayload excludes hmacSignature + piiScanResult", () => {
|
|
70
|
+
const json = canonicalSignablePayload(sample);
|
|
71
|
+
expect(json).not.toContain("hmacSignature");
|
|
72
|
+
expect(json).not.toContain("piiScanResult");
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
test("canonicalSignablePayload includes hopCount", () => {
|
|
76
|
+
const json = canonicalSignablePayload(sample);
|
|
77
|
+
expect(JSON.parse(json).hopCount).toBe(0);
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
test("mutating the source envelope changes the payload", () => {
|
|
81
|
+
const before = canonicalSignablePayload(sample);
|
|
82
|
+
const tampered: FederationEnvelope = { ...sample, payload: { ok: false } };
|
|
83
|
+
const after = canonicalSignablePayload(tampered);
|
|
84
|
+
expect(before).not.toBe(after);
|
|
85
|
+
});
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
describe("federation/envelope — age", () => {
|
|
89
|
+
test("envelopeAgeMs is 0 for a now() envelope", () => {
|
|
90
|
+
const env: FederationEnvelope = {
|
|
91
|
+
envelopeId: "e", sourceNodeId: "a", targetNodeId: "b",
|
|
92
|
+
sessionId: "s", messageType: "heartbeat",
|
|
93
|
+
payload: null, timestamp: new Date().toISOString(),
|
|
94
|
+
nonce: "n", hmacSignature: "h", piiScanResult: emptyScanResult(),
|
|
95
|
+
};
|
|
96
|
+
expect(envelopeAgeMs(env)).toBeGreaterThanOrEqual(0);
|
|
97
|
+
expect(envelopeAgeMs(env)).toBeLessThan(100);
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
test("envelopeAgeMs is large for an old envelope", () => {
|
|
101
|
+
const old = new Date(Date.now() - 10 * 60 * 1000).toISOString();
|
|
102
|
+
const env: FederationEnvelope = {
|
|
103
|
+
envelopeId: "e", sourceNodeId: "a", targetNodeId: "b",
|
|
104
|
+
sessionId: "s", messageType: "heartbeat",
|
|
105
|
+
payload: null, timestamp: old,
|
|
106
|
+
nonce: "n", hmacSignature: "h", piiScanResult: emptyScanResult(),
|
|
107
|
+
};
|
|
108
|
+
expect(envelopeAgeMs(env)).toBeGreaterThanOrEqual(10 * 60 * 1000);
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
test("envelopeAgeMs clamps invalid timestamps to Infinity", () => {
|
|
112
|
+
const env: FederationEnvelope = {
|
|
113
|
+
envelopeId: "e", sourceNodeId: "a", targetNodeId: "b",
|
|
114
|
+
sessionId: "s", messageType: "heartbeat",
|
|
115
|
+
payload: null, timestamp: "not-a-date",
|
|
116
|
+
nonce: "n", hmacSignature: "h", piiScanResult: emptyScanResult(),
|
|
117
|
+
};
|
|
118
|
+
expect(envelopeAgeMs(env)).toBe(Number.POSITIVE_INFINITY);
|
|
119
|
+
});
|
|
120
|
+
});
|
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* federation/hmac.test.ts — F-038 HMAC + nonce tests.
|
|
3
|
+
*
|
|
4
|
+
* Covers:
|
|
5
|
+
* - signEnvelope produces a stable 64-char hex digest.
|
|
6
|
+
* - verifySignature accepts a fresh signed envelope.
|
|
7
|
+
* - verifySignature rejects a tampered envelope.
|
|
8
|
+
* - verifySignature rejects an expired envelope.
|
|
9
|
+
* - NonceCache rejects duplicates within the window.
|
|
10
|
+
* - NonceCache evicts old entries (size capped).
|
|
11
|
+
* - freshNonce returns crypto.randomUUID() — 128-bit random.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { describe, test, expect, beforeEach } from "vitest";
|
|
15
|
+
import {
|
|
16
|
+
DEFAULT_NONCE_WINDOW_MS,
|
|
17
|
+
freshNonce,
|
|
18
|
+
NonceCache,
|
|
19
|
+
signEnvelope,
|
|
20
|
+
verifySignature,
|
|
21
|
+
} from "../../src/federation/hmac.js";
|
|
22
|
+
import {
|
|
23
|
+
canonicalSignablePayload,
|
|
24
|
+
emptyScanResult,
|
|
25
|
+
type FederationEnvelope,
|
|
26
|
+
} from "../../src/federation/envelope.js";
|
|
27
|
+
|
|
28
|
+
const SECRET = "test-secret-123";
|
|
29
|
+
|
|
30
|
+
function makeEnvelope(overrides: Partial<FederationEnvelope> = {}): FederationEnvelope {
|
|
31
|
+
return {
|
|
32
|
+
envelopeId: "env-1",
|
|
33
|
+
sourceNodeId: "node-A",
|
|
34
|
+
targetNodeId: "node-B",
|
|
35
|
+
sessionId: "sess-1",
|
|
36
|
+
messageType: "heartbeat",
|
|
37
|
+
payload: { ok: true },
|
|
38
|
+
timestamp: new Date().toISOString(),
|
|
39
|
+
nonce: freshNonce(),
|
|
40
|
+
hmacSignature: "",
|
|
41
|
+
piiScanResult: emptyScanResult(),
|
|
42
|
+
...overrides,
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
describe("federation/hmac — signEnvelope", () => {
|
|
47
|
+
test("produces a 64-char hex digest", () => {
|
|
48
|
+
const e = makeEnvelope();
|
|
49
|
+
const sig = signEnvelope(e, SECRET);
|
|
50
|
+
expect(sig).toMatch(/^[0-9a-f]{64}$/);
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
test("deterministic for the same payload", () => {
|
|
54
|
+
const e = makeEnvelope({ nonce: "n1", timestamp: "2026-07-12T00:00:00.000Z" });
|
|
55
|
+
const s1 = signEnvelope(e, SECRET);
|
|
56
|
+
const s2 = signEnvelope(e, SECRET);
|
|
57
|
+
expect(s1).toBe(s2);
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
test("different secret → different signature", () => {
|
|
61
|
+
const e = makeEnvelope({ nonce: "n1", timestamp: "2026-07-12T00:00:00.000Z" });
|
|
62
|
+
const a = signEnvelope(e, "secret-A");
|
|
63
|
+
const b = signEnvelope(e, "secret-B");
|
|
64
|
+
expect(a).not.toBe(b);
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
test("rejects empty secret", () => {
|
|
68
|
+
const e = makeEnvelope();
|
|
69
|
+
expect(() => signEnvelope(e, "")).toThrow(/non-empty/);
|
|
70
|
+
});
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
describe("federation/hmac — verifySignature", () => {
|
|
74
|
+
let envelope: FederationEnvelope;
|
|
75
|
+
|
|
76
|
+
beforeEach(() => {
|
|
77
|
+
envelope = makeEnvelope();
|
|
78
|
+
envelope.hmacSignature = signEnvelope(envelope, SECRET);
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
test("accepts a fresh signed envelope with empty seen set", () => {
|
|
82
|
+
const r = verifySignature(envelope, SECRET, new Set());
|
|
83
|
+
expect(r.ok).toBe(true);
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
test("rejects tampered payload", () => {
|
|
87
|
+
const tampered: FederationEnvelope = { ...envelope, payload: { evil: true } };
|
|
88
|
+
const r = verifySignature(tampered, SECRET, new Set());
|
|
89
|
+
expect(r.ok).toBe(false);
|
|
90
|
+
if (!r.ok) expect(r.reason).toBe("signature_mismatch");
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
test("rejects wrong secret", () => {
|
|
94
|
+
const r = verifySignature(envelope, "wrong-secret", new Set());
|
|
95
|
+
expect(r.ok).toBe(false);
|
|
96
|
+
if (!r.ok) expect(r.reason).toBe("signature_mismatch");
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
test("rejects envelope past freshness window", () => {
|
|
100
|
+
const old = makeEnvelope({
|
|
101
|
+
timestamp: new Date(Date.now() - DEFAULT_NONCE_WINDOW_MS - 1000).toISOString(),
|
|
102
|
+
});
|
|
103
|
+
old.hmacSignature = signEnvelope(old, SECRET);
|
|
104
|
+
const r = verifySignature(old, SECRET, new Set());
|
|
105
|
+
expect(r.ok).toBe(false);
|
|
106
|
+
if (!r.ok) expect(r.reason).toBe("envelope_expired");
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
test("rejects envelope from the far future", () => {
|
|
110
|
+
const future = makeEnvelope({
|
|
111
|
+
timestamp: new Date(Date.now() + DEFAULT_NONCE_WINDOW_MS + 60_000).toISOString(),
|
|
112
|
+
});
|
|
113
|
+
future.hmacSignature = signEnvelope(future, SECRET);
|
|
114
|
+
const r = verifySignature(future, SECRET, new Set());
|
|
115
|
+
expect(r.ok).toBe(false);
|
|
116
|
+
if (!r.ok) expect(r.reason).toBe("envelope_from_future");
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
test("rejects nonce seen in the seen-set (replay)", () => {
|
|
120
|
+
const seen = new Set([envelope.nonce]);
|
|
121
|
+
const r = verifySignature(envelope, SECRET, seen);
|
|
122
|
+
expect(r.ok).toBe(false);
|
|
123
|
+
if (!r.ok) expect(r.reason).toBe("nonce_replay");
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
test("rejects empty secret", () => {
|
|
127
|
+
const r = verifySignature(envelope, "", new Set());
|
|
128
|
+
expect(r.ok).toBe(false);
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
test("rejects empty signature", () => {
|
|
132
|
+
const e = makeEnvelope({ hmacSignature: "" });
|
|
133
|
+
const r = verifySignature(e, SECRET, new Set());
|
|
134
|
+
expect(r.ok).toBe(false);
|
|
135
|
+
});
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
describe("federation/hmac — NonceCache", () => {
|
|
139
|
+
test("check() returns true for fresh nonce, false for replay", () => {
|
|
140
|
+
const cache = new NonceCache();
|
|
141
|
+
expect(cache.check("n1")).toBe(true);
|
|
142
|
+
expect(cache.check("n1")).toBe(false);
|
|
143
|
+
expect(cache.check("n2")).toBe(true);
|
|
144
|
+
expect(cache.size()).toBe(2);
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
test("has() reflects the cache state without side effects", () => {
|
|
148
|
+
const cache = new NonceCache();
|
|
149
|
+
cache.check("n1");
|
|
150
|
+
expect(cache.has("n1")).toBe(true);
|
|
151
|
+
expect(cache.has("n2")).toBe(false);
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
test("clear() empties the cache", () => {
|
|
155
|
+
const cache = new NonceCache();
|
|
156
|
+
cache.check("n1");
|
|
157
|
+
cache.check("n2");
|
|
158
|
+
cache.clear();
|
|
159
|
+
expect(cache.size()).toBe(0);
|
|
160
|
+
expect(cache.check("n1")).toBe(true); // fresh again
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
test("evicts entries past the window", () => {
|
|
164
|
+
const cache = new NonceCache(1000); // 1s window
|
|
165
|
+
const t = Date.now();
|
|
166
|
+
expect(cache.check("n1", t)).toBe(true);
|
|
167
|
+
// 2 seconds later — entry should have expired.
|
|
168
|
+
expect(cache.check("n1", t + 2000)).toBe(true);
|
|
169
|
+
});
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
describe("federation/hmac — freshNonce", () => {
|
|
173
|
+
test("returns a 128-bit random UUID", () => {
|
|
174
|
+
const a = freshNonce();
|
|
175
|
+
const b = freshNonce();
|
|
176
|
+
expect(a).not.toBe(b);
|
|
177
|
+
expect(a).toMatch(/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/);
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
test("produces 1000 unique nonces (probabilistic)", () => {
|
|
181
|
+
const seen = new Set<string>();
|
|
182
|
+
for (let i = 0; i < 1000; i++) seen.add(freshNonce());
|
|
183
|
+
expect(seen.size).toBe(1000);
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
test("is used by sign() in the orchestrator", () => {
|
|
187
|
+
// Sanity — round-trip through canonicalSignablePayload works.
|
|
188
|
+
const e = makeEnvelope();
|
|
189
|
+
const sig = signEnvelope(e, SECRET);
|
|
190
|
+
expect(typeof sig).toBe("string");
|
|
191
|
+
expect(sig.length).toBe(64);
|
|
192
|
+
void canonicalSignablePayload;
|
|
193
|
+
});
|
|
194
|
+
});
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* federation/orchestrator.test.ts — F-038 orchestrator smoke tests.
|
|
3
|
+
*
|
|
4
|
+
* Covers the end-to-end `createFederation()` flow:
|
|
5
|
+
* - sign() produces a signed envelope.
|
|
6
|
+
* - receive() admits a fresh signed envelope.
|
|
7
|
+
* - PII redaction applies on sign() (soc2 mode).
|
|
8
|
+
* - status() returns the documented snapshot shape.
|
|
9
|
+
* - Two-instance roundtrip with shared HMAC secret.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { describe, test, expect, beforeEach, afterEach } from "vitest";
|
|
13
|
+
import { mkdtempSync, rmSync } from "node:fs";
|
|
14
|
+
import { tmpdir } from "node:os";
|
|
15
|
+
import { join } from "node:path";
|
|
16
|
+
import {
|
|
17
|
+
createFederation,
|
|
18
|
+
type FederationHandle,
|
|
19
|
+
} from "../../src/federation/index.js";
|
|
20
|
+
|
|
21
|
+
describe("federation/orchestrator — single-instance flow", () => {
|
|
22
|
+
let dir: string;
|
|
23
|
+
let handle: FederationHandle;
|
|
24
|
+
|
|
25
|
+
beforeEach(() => {
|
|
26
|
+
dir = mkdtempSync(join(tmpdir(), "bizar-fed-"));
|
|
27
|
+
handle = createFederation({
|
|
28
|
+
nodeId: "node-A",
|
|
29
|
+
secret: "shared-secret",
|
|
30
|
+
auditPath: join(dir, "audit.log"),
|
|
31
|
+
budgetPath: join(dir, "budget.json"),
|
|
32
|
+
});
|
|
33
|
+
handle.services.trust.setAllowlisted("node-B", true);
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
afterEach(() => {
|
|
37
|
+
rmSync(dir, { recursive: true, force: true });
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
test("sign() produces a signed envelope with non-empty signature", () => {
|
|
41
|
+
const env = handle.sign({
|
|
42
|
+
targetNodeId: "node-B",
|
|
43
|
+
sessionId: "s1",
|
|
44
|
+
messageType: "heartbeat",
|
|
45
|
+
payload: "ping",
|
|
46
|
+
});
|
|
47
|
+
expect(env.hmacSignature).toMatch(/^[0-9a-f]{64}$/);
|
|
48
|
+
expect(env.nonce).toMatch(/^[0-9a-f-]{36}$/);
|
|
49
|
+
expect(env.sourceNodeId).toBe("node-A");
|
|
50
|
+
expect(env.targetNodeId).toBe("node-B");
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
test("receive() admits a fresh signed envelope from an allowlisted peer", () => {
|
|
54
|
+
const peer = createFederation({
|
|
55
|
+
nodeId: "node-B",
|
|
56
|
+
secret: "shared-secret",
|
|
57
|
+
auditPath: join(dir, "audit-B.log"),
|
|
58
|
+
budgetPath: join(dir, "budget-B.json"),
|
|
59
|
+
});
|
|
60
|
+
const sent = peer.sign({
|
|
61
|
+
targetNodeId: "node-A",
|
|
62
|
+
sessionId: "s1",
|
|
63
|
+
messageType: "heartbeat",
|
|
64
|
+
payload: "hello",
|
|
65
|
+
});
|
|
66
|
+
const r = handle.receive(sent);
|
|
67
|
+
expect(r.hmacValid).toBe(true);
|
|
68
|
+
expect(r.trusted).toBe(true);
|
|
69
|
+
expect(r.policyAllowed).toBe(true);
|
|
70
|
+
expect(r.rejectionReason).toBeUndefined();
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
test("receive() rejects an envelope from a non-allowlisted peer", () => {
|
|
74
|
+
const peer = createFederation({
|
|
75
|
+
nodeId: "node-X",
|
|
76
|
+
secret: "shared-secret",
|
|
77
|
+
auditPath: join(dir, "audit-X.log"),
|
|
78
|
+
budgetPath: join(dir, "budget-X.json"),
|
|
79
|
+
});
|
|
80
|
+
const sent = peer.sign({
|
|
81
|
+
targetNodeId: "node-A",
|
|
82
|
+
sessionId: "s1",
|
|
83
|
+
messageType: "heartbeat",
|
|
84
|
+
payload: "hello",
|
|
85
|
+
});
|
|
86
|
+
const r = handle.receive(sent);
|
|
87
|
+
expect(r.hmacValid).toBe(true);
|
|
88
|
+
expect(r.trusted).toBe(false);
|
|
89
|
+
expect(r.rejectionReason).toMatch(/trust/);
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
test("receive() rejects a tampered envelope", () => {
|
|
93
|
+
const peer = createFederation({
|
|
94
|
+
nodeId: "node-B",
|
|
95
|
+
secret: "shared-secret",
|
|
96
|
+
auditPath: join(dir, "audit-B.log"),
|
|
97
|
+
budgetPath: join(dir, "budget-B.json"),
|
|
98
|
+
});
|
|
99
|
+
const sent = peer.sign({
|
|
100
|
+
targetNodeId: "node-A",
|
|
101
|
+
sessionId: "s1",
|
|
102
|
+
messageType: "heartbeat",
|
|
103
|
+
payload: "hello",
|
|
104
|
+
});
|
|
105
|
+
const tampered = { ...sent, payload: "tampered" };
|
|
106
|
+
const r = handle.receive(tampered);
|
|
107
|
+
expect(r.hmacValid).toBe(false);
|
|
108
|
+
expect(r.rejectionReason).toMatch(/hmac/);
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
test("PII redaction applies on sign() under soc2 mode", () => {
|
|
112
|
+
const env = handle.sign({
|
|
113
|
+
targetNodeId: "node-B",
|
|
114
|
+
sessionId: "s1",
|
|
115
|
+
messageType: "context-share",
|
|
116
|
+
payload: "contact alice@example.com",
|
|
117
|
+
});
|
|
118
|
+
expect(env.payload).not.toContain("alice@example.com");
|
|
119
|
+
expect(env.payload).toContain("[REDACTED:email]");
|
|
120
|
+
expect(env.piiScanResult.scanned).toBe(true);
|
|
121
|
+
expect(env.piiScanResult.piiFound).toBe(true);
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
test("skipPii disables redaction", () => {
|
|
125
|
+
const env = handle.sign({
|
|
126
|
+
targetNodeId: "node-B",
|
|
127
|
+
sessionId: "s1",
|
|
128
|
+
messageType: "context-share",
|
|
129
|
+
payload: "contact alice@example.com",
|
|
130
|
+
skipPii: true,
|
|
131
|
+
});
|
|
132
|
+
expect(env.payload).toBe("contact alice@example.com");
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
test("setPiiMode / getPiiMode round-trip", () => {
|
|
136
|
+
expect(handle.getPiiMode()).toBe("soc2");
|
|
137
|
+
handle.setPiiMode("gdpr");
|
|
138
|
+
expect(handle.getPiiMode()).toBe("gdpr");
|
|
139
|
+
expect(() => handle.setPiiMode("nope" as never)).toThrow();
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
test("status() returns the documented snapshot shape", () => {
|
|
143
|
+
const snap = handle.status();
|
|
144
|
+
expect(snap.nodeId).toBe("node-A");
|
|
145
|
+
expect(snap.auditPath).toContain("audit.log");
|
|
146
|
+
expect(snap.budget.path).toContain("budget.json");
|
|
147
|
+
expect(Array.isArray(snap.budget.perPeer)).toBe(true);
|
|
148
|
+
expect(typeof snap.nonceCacheSize).toBe("number");
|
|
149
|
+
expect(typeof snap.auditSizeBytes).toBe("number");
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
test("constructor requires nodeId + secret", () => {
|
|
153
|
+
expect(() => createFederation({ nodeId: "", secret: "x" } as never)).toThrow(/nodeId/);
|
|
154
|
+
expect(() => createFederation({ nodeId: "x", secret: "" } as never)).toThrow(/secret/);
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
test("nonce replay is rejected on second delivery", () => {
|
|
158
|
+
const peer = createFederation({
|
|
159
|
+
nodeId: "node-B",
|
|
160
|
+
secret: "shared-secret",
|
|
161
|
+
auditPath: join(dir, "audit-B.log"),
|
|
162
|
+
budgetPath: join(dir, "budget-B.json"),
|
|
163
|
+
});
|
|
164
|
+
const sent = peer.sign({
|
|
165
|
+
targetNodeId: "node-A",
|
|
166
|
+
sessionId: "s1",
|
|
167
|
+
messageType: "heartbeat",
|
|
168
|
+
payload: "hello",
|
|
169
|
+
});
|
|
170
|
+
const r1 = handle.receive(sent);
|
|
171
|
+
const r2 = handle.receive(sent);
|
|
172
|
+
expect(r1.hmacValid).toBe(true);
|
|
173
|
+
expect(r2.hmacValid).toBe(false);
|
|
174
|
+
if (r2.rejectionReason) expect(r2.rejectionReason).toMatch(/nonce_replay/);
|
|
175
|
+
});
|
|
176
|
+
});
|