@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,269 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* federation/budget.ts — FederationBudget: per-peer reserved →
|
|
3
|
+
* committed → released state machine for token / USD spend.
|
|
4
|
+
*
|
|
5
|
+
* F-038 — ported from ruflo
|
|
6
|
+
* `v3/@claude-flow/plugin-agent-federation/src/domain/value-objects/federation-budget.ts`
|
|
7
|
+
* (validateBudget + enforceBudget shape) and folded into a class
|
|
8
|
+
* that tracks actual spend per peer across calls.
|
|
9
|
+
*
|
|
10
|
+
* State machine (per peer):
|
|
11
|
+
* reserved: amount is held back (the peer is going to spend ≤ N).
|
|
12
|
+
* committed: amount was actually spent (the call completed and we
|
|
13
|
+
* know the real cost).
|
|
14
|
+
* released: amount returned to the peer (call failed before
|
|
15
|
+
* commit; we give the hold back).
|
|
16
|
+
*
|
|
17
|
+
* Persistence: optional JSON snapshot at
|
|
18
|
+
* `.harness/federation-budget.json` so the budget survives a
|
|
19
|
+
* Claude Code session restart. The file is gitignored.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync, renameSync } from "node:fs";
|
|
23
|
+
import { dirname } from "node:path";
|
|
24
|
+
|
|
25
|
+
/** Default budget JSON path. */
|
|
26
|
+
export const DEFAULT_BUDGET_PATH = ".harness/federation-budget.json";
|
|
27
|
+
|
|
28
|
+
/** Hard ceilings — matches ruflo's MAX_*_CEILING values, kept here
|
|
29
|
+
* so callers can't ask for absurdly large budgets. */
|
|
30
|
+
export const MAX_USD_CEILING = 1_000_000;
|
|
31
|
+
export const MAX_TOKENS_CEILING = 1_000_000_000;
|
|
32
|
+
|
|
33
|
+
export type ReserveResult =
|
|
34
|
+
| { ok: true; reservationId: string }
|
|
35
|
+
| { ok: false; reason: "peer_over_budget" | "invalid_amount" };
|
|
36
|
+
|
|
37
|
+
export interface PerPeerBudget {
|
|
38
|
+
readonly nodeId: string;
|
|
39
|
+
readonly maxTokens: number;
|
|
40
|
+
readonly maxUsd: number;
|
|
41
|
+
readonly maxHops: number;
|
|
42
|
+
readonly spentTokens: number;
|
|
43
|
+
readonly spentUsd: number;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export interface PersistedBudget {
|
|
47
|
+
readonly version: 1;
|
|
48
|
+
readonly updatedAt: string;
|
|
49
|
+
readonly perPeer: PerPeerBudget[];
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
interface Reservation {
|
|
53
|
+
readonly id: string;
|
|
54
|
+
readonly nodeId: string;
|
|
55
|
+
readonly tokens: number;
|
|
56
|
+
readonly usd: number;
|
|
57
|
+
readonly atMs: number;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
let reservationCounter = 0;
|
|
61
|
+
|
|
62
|
+
function nextReservationId(): string {
|
|
63
|
+
reservationCounter++;
|
|
64
|
+
// 128-bit random suffix avoids collision across processes.
|
|
65
|
+
return `res-${Date.now().toString(36)}-${reservationCounter.toString(36)}-${Math.random().toString(36).slice(2, 10)}`;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** Pure validator — borrowed from ruflo's `validateBudget` and
|
|
69
|
+
* simplified. Returns a discriminated result. */
|
|
70
|
+
export function validateBudgetInput(raw: unknown): { ok: true; maxTokens: number; maxUsd: number; maxHops: number } | { ok: false; error: string } {
|
|
71
|
+
if (raw == null) {
|
|
72
|
+
return {
|
|
73
|
+
ok: true,
|
|
74
|
+
maxTokens: MAX_TOKENS_CEILING,
|
|
75
|
+
maxUsd: MAX_USD_CEILING,
|
|
76
|
+
maxHops: 8,
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
if (typeof raw !== "object" || Array.isArray(raw)) {
|
|
80
|
+
return { ok: false, error: "budget must be an object" };
|
|
81
|
+
}
|
|
82
|
+
const obj = raw as Record<string, unknown>;
|
|
83
|
+
const hops = typeof obj.maxHops === "number" && Number.isFinite(obj.maxHops) ? obj.maxHops : 8;
|
|
84
|
+
const tokens = typeof obj.maxTokens === "number" && Number.isFinite(obj.maxTokens) ? obj.maxTokens : MAX_TOKENS_CEILING;
|
|
85
|
+
const usd = typeof obj.maxUsd === "number" && Number.isFinite(obj.maxUsd) ? obj.maxUsd : MAX_USD_CEILING;
|
|
86
|
+
if (hops < 0 || hops > 64) return { ok: false, error: "maxHops out of range" };
|
|
87
|
+
if (tokens < 0 || tokens > MAX_TOKENS_CEILING) return { ok: false, error: "maxTokens out of range" };
|
|
88
|
+
if (usd < 0 || usd > MAX_USD_CEILING) return { ok: false, error: "maxUsd out of range" };
|
|
89
|
+
return { ok: true, maxTokens: tokens, maxUsd: usd, maxHops: hops };
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export interface FederationBudgetOpts {
|
|
93
|
+
/** Override the JSON persistence path. */
|
|
94
|
+
readonly path?: string;
|
|
95
|
+
/** Default per-peer limits applied when a peer is first seen. */
|
|
96
|
+
readonly defaultMaxTokens?: number;
|
|
97
|
+
readonly defaultMaxUsd?: number;
|
|
98
|
+
readonly defaultMaxHops?: number;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
export class FederationBudget {
|
|
102
|
+
private readonly path: string;
|
|
103
|
+
private readonly perPeer = new Map<string, PerPeerBudget>();
|
|
104
|
+
private readonly reservations = new Map<string, Reservation>();
|
|
105
|
+
private readonly defaults: { maxTokens: number; maxUsd: number; maxHops: number };
|
|
106
|
+
|
|
107
|
+
constructor(opts: FederationBudgetOpts = {}) {
|
|
108
|
+
this.path = opts.path ?? DEFAULT_BUDGET_PATH;
|
|
109
|
+
this.defaults = {
|
|
110
|
+
maxTokens: opts.defaultMaxTokens ?? MAX_TOKENS_CEILING,
|
|
111
|
+
maxUsd: opts.defaultMaxUsd ?? MAX_USD_CEILING,
|
|
112
|
+
maxHops: opts.defaultMaxHops ?? 8,
|
|
113
|
+
};
|
|
114
|
+
this.loadFromDisk();
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** Get or lazily-create the per-peer budget record. */
|
|
118
|
+
getOrCreate(nodeId: string): PerPeerBudget {
|
|
119
|
+
let existing = this.perPeer.get(nodeId);
|
|
120
|
+
if (!existing) {
|
|
121
|
+
existing = {
|
|
122
|
+
nodeId,
|
|
123
|
+
maxTokens: this.defaults.maxTokens,
|
|
124
|
+
maxUsd: this.defaults.maxUsd,
|
|
125
|
+
maxHops: this.defaults.maxHops,
|
|
126
|
+
spentTokens: 0,
|
|
127
|
+
spentUsd: 0,
|
|
128
|
+
};
|
|
129
|
+
this.perPeer.set(nodeId, existing);
|
|
130
|
+
}
|
|
131
|
+
return existing;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/** Override the limits for a specific peer. */
|
|
135
|
+
setLimits(nodeId: string, limits: { maxTokens?: number; maxUsd?: number; maxHops?: number }): void {
|
|
136
|
+
const peer = this.getOrCreate(nodeId);
|
|
137
|
+
this.perPeer.set(nodeId, {
|
|
138
|
+
...peer,
|
|
139
|
+
maxTokens: limits.maxTokens ?? peer.maxTokens,
|
|
140
|
+
maxUsd: limits.maxUsd ?? peer.maxUsd,
|
|
141
|
+
maxHops: limits.maxHops ?? peer.maxHops,
|
|
142
|
+
});
|
|
143
|
+
this.saveToDisk();
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/** Reserve `tokens` + `usd` against a peer's budget. The caller
|
|
147
|
+
* MUST eventually `commit()` or `release()` — un-released
|
|
148
|
+
* reservations are surfaced via `outstanding()`. */
|
|
149
|
+
reserve(nodeId: string, tokens: number, usd: number): ReserveResult {
|
|
150
|
+
if (!Number.isFinite(tokens) || tokens < 0 || !Number.isFinite(usd) || usd < 0) {
|
|
151
|
+
return { ok: false, reason: "invalid_amount" };
|
|
152
|
+
}
|
|
153
|
+
const peer = this.getOrCreate(nodeId);
|
|
154
|
+
const remainingTokens = peer.maxTokens - peer.spentTokens;
|
|
155
|
+
const remainingUsd = peer.maxUsd - peer.spentUsd;
|
|
156
|
+
if (tokens > remainingTokens || usd > remainingUsd) {
|
|
157
|
+
return { ok: false, reason: "peer_over_budget" };
|
|
158
|
+
}
|
|
159
|
+
const id = nextReservationId();
|
|
160
|
+
this.reservations.set(id, {
|
|
161
|
+
id,
|
|
162
|
+
nodeId,
|
|
163
|
+
tokens,
|
|
164
|
+
usd,
|
|
165
|
+
atMs: Date.now(),
|
|
166
|
+
});
|
|
167
|
+
// Optimistically charge the spend against the budget so a
|
|
168
|
+
// concurrent reserve from the same peer cannot double-spend.
|
|
169
|
+
// If commit() reports less, the diff is refunded. If the call
|
|
170
|
+
// fails and release() is called, the full reservation is
|
|
171
|
+
// refunded.
|
|
172
|
+
this.perPeer.set(nodeId, {
|
|
173
|
+
...peer,
|
|
174
|
+
spentTokens: peer.spentTokens + tokens,
|
|
175
|
+
spentUsd: peer.spentUsd + usd,
|
|
176
|
+
});
|
|
177
|
+
return { ok: true, reservationId: id };
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/** Commit a reservation — caller reports the ACTUAL spend which
|
|
181
|
+
* may be less than the reserved amount. Difference is refunded. */
|
|
182
|
+
commit(reservationId: string, actual: { tokens?: number; usd?: number } = {}): boolean {
|
|
183
|
+
const r = this.reservations.get(reservationId);
|
|
184
|
+
if (!r) return false;
|
|
185
|
+
const actualTokens = Math.max(0, actual.tokens ?? r.tokens);
|
|
186
|
+
const actualUsd = Math.max(0, actual.usd ?? r.usd);
|
|
187
|
+
const peer = this.perPeer.get(r.nodeId);
|
|
188
|
+
if (!peer) {
|
|
189
|
+
this.reservations.delete(reservationId);
|
|
190
|
+
return false;
|
|
191
|
+
}
|
|
192
|
+
// Refund the difference between reserved and actual.
|
|
193
|
+
const tokenRefund = r.tokens - actualTokens;
|
|
194
|
+
const usdRefund = r.usd - actualUsd;
|
|
195
|
+
this.perPeer.set(r.nodeId, {
|
|
196
|
+
...peer,
|
|
197
|
+
spentTokens: Math.max(0, peer.spentTokens - tokenRefund),
|
|
198
|
+
spentUsd: Math.max(0, peer.spentUsd - usdRefund),
|
|
199
|
+
});
|
|
200
|
+
this.reservations.delete(reservationId);
|
|
201
|
+
this.saveToDisk();
|
|
202
|
+
return true;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/** Release a reservation — full refund. */
|
|
206
|
+
release(reservationId: string): boolean {
|
|
207
|
+
const r = this.reservations.get(reservationId);
|
|
208
|
+
if (!r) return false;
|
|
209
|
+
const peer = this.perPeer.get(r.nodeId);
|
|
210
|
+
if (peer) {
|
|
211
|
+
this.perPeer.set(r.nodeId, {
|
|
212
|
+
...peer,
|
|
213
|
+
spentTokens: Math.max(0, peer.spentTokens - r.tokens),
|
|
214
|
+
spentUsd: Math.max(0, peer.spentUsd - r.usd),
|
|
215
|
+
});
|
|
216
|
+
}
|
|
217
|
+
this.reservations.delete(reservationId);
|
|
218
|
+
this.saveToDisk();
|
|
219
|
+
return true;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/** List outstanding reservations. */
|
|
223
|
+
outstanding(): Reservation[] {
|
|
224
|
+
return [...this.reservations.values()];
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/** Snapshot every per-peer budget — for the status MCP tool. */
|
|
228
|
+
snapshot(): PerPeerBudget[] {
|
|
229
|
+
return [...this.perPeer.values()];
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
/** Serialize to the persisted JSON shape. */
|
|
233
|
+
toJSON(): PersistedBudget {
|
|
234
|
+
return {
|
|
235
|
+
version: 1,
|
|
236
|
+
updatedAt: new Date().toISOString(),
|
|
237
|
+
perPeer: this.snapshot(),
|
|
238
|
+
};
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
/** Write to disk (atomic tmp + rename). */
|
|
242
|
+
saveToDisk(): void {
|
|
243
|
+
const dir = dirname(this.path);
|
|
244
|
+
if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
|
|
245
|
+
const tmp = `${this.path}.tmp`;
|
|
246
|
+
try {
|
|
247
|
+
writeFileSync(tmp, JSON.stringify(this.toJSON(), null, 2), "utf-8");
|
|
248
|
+
renameSync(tmp, this.path);
|
|
249
|
+
} catch {
|
|
250
|
+
/* best-effort */
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
/** Re-read from disk, replacing in-memory state. */
|
|
255
|
+
loadFromDisk(): void {
|
|
256
|
+
if (!existsSync(this.path)) return;
|
|
257
|
+
try {
|
|
258
|
+
const raw = readFileSync(this.path, "utf-8");
|
|
259
|
+
const parsed = JSON.parse(raw) as PersistedBudget;
|
|
260
|
+
if (parsed.version !== 1 || !Array.isArray(parsed.perPeer)) return;
|
|
261
|
+
this.perPeer.clear();
|
|
262
|
+
for (const p of parsed.perPeer) {
|
|
263
|
+
if (typeof p.nodeId === "string") this.perPeer.set(p.nodeId, p);
|
|
264
|
+
}
|
|
265
|
+
} catch {
|
|
266
|
+
/* malformed — keep defaults */
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
}
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* federation/envelope.ts — Federation envelope type and message kinds.
|
|
3
|
+
*
|
|
4
|
+
* F-038 — ported from ruflo
|
|
5
|
+
* `v3/@claude-flow/plugin-agent-federation/src/domain/entities/federation-envelope.ts:1-20`
|
|
6
|
+
* (the 16-kind `FederationMessageType` union + `FederationEnvelope` shape).
|
|
7
|
+
*
|
|
8
|
+
* This module is intentionally minimal: it exports ONLY types + a
|
|
9
|
+
* tiny `emptyScanResult()` helper. The runtime signing/verification
|
|
10
|
+
* lives in `./hmac.ts`, PII in `./pii.ts`, audit in `./audit.ts`,
|
|
11
|
+
* budget in `./budget.ts`. Keeping the type isolated here avoids a
|
|
12
|
+
* cycle with the signing/policy modules that import from this file.
|
|
13
|
+
*
|
|
14
|
+
* Backward-compat note: Bizar's v6.4.0 release (current `master`)
|
|
15
|
+
* shipped zero federation code, so anything we add is purely
|
|
16
|
+
* additive — no consumers can break. The 16 message kinds are
|
|
17
|
+
* defined as a `readonly` array AND a union type so the validator
|
|
18
|
+
* surface (e.g. PolicyEngine) can iterate without re-listing them.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Wire-level message kinds carried inside a federation envelope.
|
|
23
|
+
* 16 total — kept identical to the ruflo source so cross-installation
|
|
24
|
+
* pairs can interoperate if both run a Bizar build ≥ v6.5.0.
|
|
25
|
+
*/
|
|
26
|
+
export const FEDERATION_MESSAGE_TYPES = [
|
|
27
|
+
"task-assignment",
|
|
28
|
+
"memory-query",
|
|
29
|
+
"memory-response",
|
|
30
|
+
"context-share",
|
|
31
|
+
"status-broadcast",
|
|
32
|
+
"trust-change",
|
|
33
|
+
"topology-change",
|
|
34
|
+
"agent-spawn",
|
|
35
|
+
"heartbeat",
|
|
36
|
+
"challenge",
|
|
37
|
+
"challenge-response",
|
|
38
|
+
"handshake-init",
|
|
39
|
+
"handshake-accept",
|
|
40
|
+
"handshake-reject",
|
|
41
|
+
"session-terminate",
|
|
42
|
+
"claim-event",
|
|
43
|
+
"agent-handoff",
|
|
44
|
+
] as const;
|
|
45
|
+
|
|
46
|
+
export type FederationMessageType = (typeof FEDERATION_MESSAGE_TYPES)[number];
|
|
47
|
+
|
|
48
|
+
/** Is `t` a recognised federation message kind? Useful for validation. */
|
|
49
|
+
export function isFederationMessageType(t: unknown): t is FederationMessageType {
|
|
50
|
+
return typeof t === "string" && (FEDERATION_MESSAGE_TYPES as readonly string[]).includes(t);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** Subset of message kinds that mutate trust / topology state — those
|
|
54
|
+
* that should require extra policy scrutiny (mirrors ruflo's
|
|
55
|
+
* CONSENSUS_REQUIRED_TYPES set, minus the consensus-vote-specific
|
|
56
|
+
* types that don't apply to the skeleton). */
|
|
57
|
+
export const TRUST_AFFECTING_MESSAGE_TYPES: ReadonlySet<FederationMessageType> = new Set<FederationMessageType>([
|
|
58
|
+
"trust-change",
|
|
59
|
+
"topology-change",
|
|
60
|
+
"agent-spawn",
|
|
61
|
+
"agent-handoff",
|
|
62
|
+
]);
|
|
63
|
+
|
|
64
|
+
/** Outcome of a PII scan over the envelope payload. Mirrors ruflo's
|
|
65
|
+
* PIIScanResult so audit consumers can read either implementation
|
|
66
|
+
* interchangeably. */
|
|
67
|
+
export type PiiScanAction = "pass" | "redact" | "hash" | "block";
|
|
68
|
+
|
|
69
|
+
export interface PiiScanDetection {
|
|
70
|
+
readonly type: string;
|
|
71
|
+
readonly action: PiiScanAction;
|
|
72
|
+
readonly confidence: number;
|
|
73
|
+
readonly count: number;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export interface PiiScanResult {
|
|
77
|
+
readonly scanned: boolean;
|
|
78
|
+
readonly piiFound: boolean;
|
|
79
|
+
readonly detections: readonly PiiScanDetection[];
|
|
80
|
+
readonly actionsApplied: readonly PiiScanAction[];
|
|
81
|
+
readonly scanDurationMs: number;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** Empty / no-op scan result — used by senders that opt out of PII
|
|
85
|
+
* scanning for the given message kind. */
|
|
86
|
+
export function emptyScanResult(): PiiScanResult {
|
|
87
|
+
return {
|
|
88
|
+
scanned: false,
|
|
89
|
+
piiFound: false,
|
|
90
|
+
detections: [],
|
|
91
|
+
actionsApplied: [],
|
|
92
|
+
scanDurationMs: 0,
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** Per-peer budget attached to every envelope. `maxTokens` and
|
|
97
|
+
* `maxUsd` are the sender's original limits for the call; the
|
|
98
|
+
* remote decrements them as the message fans out. `maxHops` is
|
|
99
|
+
* the absolute hop ceiling (sender's value). */
|
|
100
|
+
export interface EnvelopeBudget {
|
|
101
|
+
readonly maxTokens: number;
|
|
102
|
+
readonly maxUsd: number;
|
|
103
|
+
readonly maxHops: number;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export interface FederationEnvelope<T = unknown> {
|
|
107
|
+
readonly envelopeId: string;
|
|
108
|
+
readonly sourceNodeId: string;
|
|
109
|
+
readonly targetNodeId: string;
|
|
110
|
+
readonly sessionId: string;
|
|
111
|
+
readonly messageType: FederationMessageType;
|
|
112
|
+
readonly payload: T;
|
|
113
|
+
readonly timestamp: string; // ISO-8601, parsed back via new Date()
|
|
114
|
+
readonly nonce: string; // 128-bit random via crypto.randomUUID()
|
|
115
|
+
readonly hmacSignature: string; // hex SHA-256 HMAC over canonical payload
|
|
116
|
+
readonly piiScanResult: PiiScanResult;
|
|
117
|
+
readonly budget?: EnvelopeBudget;
|
|
118
|
+
readonly hopCount?: number; // present on re-emit, defaults to 0 on origin
|
|
119
|
+
readonly hopsRemaining?: number;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/** Canonical JSON form signed by HMAC. Field order MUST stay stable
|
|
123
|
+
* across sender + receiver or signatures will mismatch. */
|
|
124
|
+
export function canonicalSignablePayload<T>(e: FederationEnvelope<T>): string {
|
|
125
|
+
const obj = {
|
|
126
|
+
envelopeId: e.envelopeId,
|
|
127
|
+
sourceNodeId: e.sourceNodeId,
|
|
128
|
+
targetNodeId: e.targetNodeId,
|
|
129
|
+
sessionId: e.sessionId,
|
|
130
|
+
messageType: e.messageType,
|
|
131
|
+
payload: e.payload,
|
|
132
|
+
timestamp: e.timestamp,
|
|
133
|
+
nonce: e.nonce,
|
|
134
|
+
hopCount: e.hopCount ?? 0,
|
|
135
|
+
};
|
|
136
|
+
return JSON.stringify(obj);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/** `Date.now()`-based envelope age in milliseconds. */
|
|
140
|
+
export function envelopeAgeMs(e: FederationEnvelope, now: number = Date.now()): number {
|
|
141
|
+
const ts = new Date(e.timestamp).getTime();
|
|
142
|
+
if (!Number.isFinite(ts)) return Number.POSITIVE_INFINITY;
|
|
143
|
+
return Math.max(0, now - ts);
|
|
144
|
+
}
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* federation/hmac.ts — HMAC-SHA-256 signing + verification for
|
|
3
|
+
* federation envelopes.
|
|
4
|
+
*
|
|
5
|
+
* F-038 — security-critical. Pinned by AGENTS.md hard constraint:
|
|
6
|
+
* "MUST use crypto.timingSafeEqual for HMAC comparison
|
|
7
|
+
* (timing-attack resistant)."
|
|
8
|
+
* "MUST use crypto.randomUUID() for nonces (128-bit random)."
|
|
9
|
+
*
|
|
10
|
+
* Nonce strategy:
|
|
11
|
+
* - Sender generates a fresh `crypto.randomUUID()` per envelope.
|
|
12
|
+
* - Receiver tracks every nonce it has seen in a TTL-bounded Set.
|
|
13
|
+
* - An envelope whose nonce appears twice is a replay → reject.
|
|
14
|
+
* - An envelope whose timestamp is older than `nonceWindowMs`
|
|
15
|
+
* (default 5 min) is also a replay → reject.
|
|
16
|
+
*
|
|
17
|
+
* No native deps. Pure Node `node:crypto`.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import { createHmac, randomUUID, timingSafeEqual } from "node:crypto";
|
|
21
|
+
import {
|
|
22
|
+
canonicalSignablePayload,
|
|
23
|
+
type FederationEnvelope,
|
|
24
|
+
} from "./envelope.js";
|
|
25
|
+
|
|
26
|
+
/** Default freshness window for HMAC verification (5 minutes). */
|
|
27
|
+
export const DEFAULT_NONCE_WINDOW_MS = 5 * 60 * 1000;
|
|
28
|
+
|
|
29
|
+
/** Cap on the receiver's seen-nonce set so it can't grow unbounded. */
|
|
30
|
+
const SEEN_NONCE_CAP = 10_000;
|
|
31
|
+
|
|
32
|
+
/** Compute a deterministic 64-char hex HMAC-SHA-256 over the envelope's
|
|
33
|
+
* canonical signable payload. */
|
|
34
|
+
export function signEnvelope<T>(
|
|
35
|
+
envelope: FederationEnvelope<T>,
|
|
36
|
+
secret: string,
|
|
37
|
+
): string {
|
|
38
|
+
if (typeof secret !== "string" || secret.length === 0) {
|
|
39
|
+
throw new Error("signEnvelope: secret must be a non-empty string");
|
|
40
|
+
}
|
|
41
|
+
const payload = canonicalSignablePayload(envelope);
|
|
42
|
+
return createHmac("sha256", secret).update(payload).digest("hex");
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export interface VerifyOptions {
|
|
46
|
+
/** Freshness window. Envelopes older than this are rejected.
|
|
47
|
+
* Defaults to 5 minutes. */
|
|
48
|
+
readonly nonceWindowMs?: number;
|
|
49
|
+
/** Optional clock-skew offset (ms) to *add* to the freshness
|
|
50
|
+
* window (e.g. for poorly-synced peers). Defaults to 0. */
|
|
51
|
+
readonly clockSkewMs?: number;
|
|
52
|
+
/** Optional explicit timestamp for deterministic tests. */
|
|
53
|
+
readonly now?: number;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export type VerifyOutcome =
|
|
57
|
+
| { ok: true }
|
|
58
|
+
| {
|
|
59
|
+
ok: false;
|
|
60
|
+
reason:
|
|
61
|
+
| "signature_mismatch"
|
|
62
|
+
| "nonce_replay"
|
|
63
|
+
| "envelope_expired"
|
|
64
|
+
| "envelope_from_future"
|
|
65
|
+
| "invalid_signature_format";
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
/** Pure verifier — no shared state. The caller owns the seen-nonce
|
|
69
|
+
* cache (see `NonceCache` below) and passes it in via `seen`. */
|
|
70
|
+
export function verifySignature<T>(
|
|
71
|
+
envelope: FederationEnvelope<T>,
|
|
72
|
+
secret: string,
|
|
73
|
+
seen: ReadonlySet<string>,
|
|
74
|
+
opts: VerifyOptions = {},
|
|
75
|
+
): VerifyOutcome {
|
|
76
|
+
if (typeof secret !== "string" || secret.length === 0) {
|
|
77
|
+
return { ok: false, reason: "invalid_signature_format" };
|
|
78
|
+
}
|
|
79
|
+
if (typeof envelope.hmacSignature !== "string" || envelope.hmacSignature.length === 0) {
|
|
80
|
+
return { ok: false, reason: "invalid_signature_format" };
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// Freshness — reject before we touch the HMAC code path so we
|
|
84
|
+
// don't burn cycles on ancient replays.
|
|
85
|
+
const now = opts.now ?? Date.now();
|
|
86
|
+
const ts = new Date(envelope.timestamp).getTime();
|
|
87
|
+
if (!Number.isFinite(ts)) return { ok: false, reason: "envelope_expired" };
|
|
88
|
+
const ageMs = now - ts;
|
|
89
|
+
const window = (opts.nonceWindowMs ?? DEFAULT_NONCE_WINDOW_MS) + (opts.clockSkewMs ?? 0);
|
|
90
|
+
if (ageMs > window) return { ok: false, reason: "envelope_expired" };
|
|
91
|
+
if (ageMs < -window) return { ok: false, reason: "envelope_from_future" };
|
|
92
|
+
|
|
93
|
+
// Nonce replay — checked AFTER freshness so a delayed-but-unique
|
|
94
|
+
// envelope doesn't get falsely fingerprinted as a replay.
|
|
95
|
+
if (seen.has(envelope.nonce)) return { ok: false, reason: "nonce_replay" };
|
|
96
|
+
|
|
97
|
+
// Constant-time signature compare. Both buffers must be the same
|
|
98
|
+
// length for timingSafeEqual; if lengths differ, signature_mismatch.
|
|
99
|
+
const expected = signEnvelope(envelope, secret);
|
|
100
|
+
const a = Buffer.from(expected, "hex");
|
|
101
|
+
let b: Buffer;
|
|
102
|
+
try {
|
|
103
|
+
b = Buffer.from(envelope.hmacSignature, "hex");
|
|
104
|
+
} catch {
|
|
105
|
+
return { ok: false, reason: "invalid_signature_format" };
|
|
106
|
+
}
|
|
107
|
+
if (a.length !== b.length) return { ok: false, reason: "signature_mismatch" };
|
|
108
|
+
if (!timingSafeEqual(a, b)) return { ok: false, reason: "signature_mismatch" };
|
|
109
|
+
|
|
110
|
+
return { ok: true };
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** Mutable nonce cache with TTL eviction + hard cap. The receiver
|
|
114
|
+
* keeps one per remote node (or a single global one for the skeleton). */
|
|
115
|
+
export class NonceCache {
|
|
116
|
+
private readonly seen = new Map<string, number>(); // nonce → expiryMs
|
|
117
|
+
private readonly windowMs: number;
|
|
118
|
+
|
|
119
|
+
constructor(windowMs: number = DEFAULT_NONCE_WINDOW_MS) {
|
|
120
|
+
this.windowMs = windowMs;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/** True if the nonce is fresh (not seen in windowMs). Adds it
|
|
124
|
+
* to the cache as a side effect. */
|
|
125
|
+
check(nonce: string, now: number = Date.now()): boolean {
|
|
126
|
+
this.evict(now);
|
|
127
|
+
if (this.seen.has(nonce)) return false;
|
|
128
|
+
if (this.seen.size >= SEEN_NONCE_CAP) {
|
|
129
|
+
// Hard cap — evict the oldest entry to make room.
|
|
130
|
+
const oldest = this.seen.keys().next().value;
|
|
131
|
+
if (oldest !== undefined) this.seen.delete(oldest);
|
|
132
|
+
}
|
|
133
|
+
this.seen.set(nonce, now + this.windowMs);
|
|
134
|
+
return true;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/** True if the nonce is in the cache (read-only). */
|
|
138
|
+
has(nonce: string): boolean {
|
|
139
|
+
return this.seen.has(nonce);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
size(): number {
|
|
143
|
+
return this.seen.size;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
clear(): void {
|
|
147
|
+
this.seen.clear();
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
private evict(now: number): void {
|
|
151
|
+
for (const [nonce, expiry] of this.seen) {
|
|
152
|
+
if (expiry <= now) this.seen.delete(nonce);
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/** Helper — produce a fresh nonce via `crypto.randomUUID()`. */
|
|
158
|
+
export function freshNonce(): string {
|
|
159
|
+
return randomUUID();
|
|
160
|
+
}
|