thevoidforge 21.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/dist/scripts/vault-read.d.ts +11 -0
- package/dist/scripts/vault-read.js +89 -0
- package/dist/scripts/voidforge.d.ts +20 -0
- package/dist/scripts/voidforge.js +404 -0
- package/dist/tsconfig.tsbuildinfo +1 -0
- package/dist/wizard/api/auth.d.ts +5 -0
- package/dist/wizard/api/auth.js +133 -0
- package/dist/wizard/api/blueprint.d.ts +45 -0
- package/dist/wizard/api/blueprint.js +184 -0
- package/dist/wizard/api/cloud-providers.d.ts +16 -0
- package/dist/wizard/api/cloud-providers.js +363 -0
- package/dist/wizard/api/credentials.d.ts +1 -0
- package/dist/wizard/api/credentials.js +258 -0
- package/dist/wizard/api/danger-room.d.ts +18 -0
- package/dist/wizard/api/danger-room.js +401 -0
- package/dist/wizard/api/deploy.d.ts +4 -0
- package/dist/wizard/api/deploy.js +164 -0
- package/dist/wizard/api/prd.d.ts +1 -0
- package/dist/wizard/api/prd.js +363 -0
- package/dist/wizard/api/project.d.ts +1 -0
- package/dist/wizard/api/project.js +239 -0
- package/dist/wizard/api/projects.d.ts +6 -0
- package/dist/wizard/api/projects.js +648 -0
- package/dist/wizard/api/provision.d.ts +4 -0
- package/dist/wizard/api/provision.js +535 -0
- package/dist/wizard/api/terminal.d.ts +25 -0
- package/dist/wizard/api/terminal.js +241 -0
- package/dist/wizard/api/users.d.ts +6 -0
- package/dist/wizard/api/users.js +244 -0
- package/dist/wizard/api/war-room.d.ts +14 -0
- package/dist/wizard/api/war-room.js +45 -0
- package/dist/wizard/lib/ad-platform-core.d.ts +6 -0
- package/dist/wizard/lib/ad-platform-core.js +1 -0
- package/dist/wizard/lib/adapters/index.d.ts +52 -0
- package/dist/wizard/lib/adapters/index.js +38 -0
- package/dist/wizard/lib/adapters/sandbox-bank.d.ts +17 -0
- package/dist/wizard/lib/adapters/sandbox-bank.js +77 -0
- package/dist/wizard/lib/adapters/sandbox.d.ts +39 -0
- package/dist/wizard/lib/adapters/sandbox.js +174 -0
- package/dist/wizard/lib/adapters/stripe.d.ts +19 -0
- package/dist/wizard/lib/adapters/stripe.js +143 -0
- package/dist/wizard/lib/adapters/types.d.ts +9 -0
- package/dist/wizard/lib/adapters/types.js +10 -0
- package/dist/wizard/lib/agent-memory.d.ts +36 -0
- package/dist/wizard/lib/agent-memory.js +114 -0
- package/dist/wizard/lib/anomaly-detection.d.ts +59 -0
- package/dist/wizard/lib/anomaly-detection.js +122 -0
- package/dist/wizard/lib/anthropic.d.ts +21 -0
- package/dist/wizard/lib/anthropic.js +105 -0
- package/dist/wizard/lib/asset-scanner.d.ts +23 -0
- package/dist/wizard/lib/asset-scanner.js +107 -0
- package/dist/wizard/lib/audit-log.d.ts +23 -0
- package/dist/wizard/lib/audit-log.js +70 -0
- package/dist/wizard/lib/autonomy-controller.d.ts +76 -0
- package/dist/wizard/lib/autonomy-controller.js +183 -0
- package/dist/wizard/lib/body-parser.d.ts +2 -0
- package/dist/wizard/lib/body-parser.js +36 -0
- package/dist/wizard/lib/build-analytics.d.ts +39 -0
- package/dist/wizard/lib/build-analytics.js +91 -0
- package/dist/wizard/lib/build-step.d.ts +21 -0
- package/dist/wizard/lib/build-step.js +104 -0
- package/dist/wizard/lib/campaign-proposer.d.ts +39 -0
- package/dist/wizard/lib/campaign-proposer.js +180 -0
- package/dist/wizard/lib/campaign-state-machine.d.ts +63 -0
- package/dist/wizard/lib/campaign-state-machine.js +114 -0
- package/dist/wizard/lib/ci-generator.d.ts +14 -0
- package/dist/wizard/lib/ci-generator.js +187 -0
- package/dist/wizard/lib/claude-merge.d.ts +38 -0
- package/dist/wizard/lib/claude-merge.js +115 -0
- package/dist/wizard/lib/codegen/erd-gen.d.ts +16 -0
- package/dist/wizard/lib/codegen/erd-gen.js +98 -0
- package/dist/wizard/lib/codegen/integrations.d.ts +18 -0
- package/dist/wizard/lib/codegen/integrations.js +189 -0
- package/dist/wizard/lib/codegen/openapi-gen.d.ts +15 -0
- package/dist/wizard/lib/codegen/openapi-gen.js +79 -0
- package/dist/wizard/lib/codegen/prisma-types.d.ts +15 -0
- package/dist/wizard/lib/codegen/prisma-types.js +44 -0
- package/dist/wizard/lib/codegen/seed-gen.d.ts +16 -0
- package/dist/wizard/lib/codegen/seed-gen.js +128 -0
- package/dist/wizard/lib/compliance.d.ts +51 -0
- package/dist/wizard/lib/compliance.js +112 -0
- package/dist/wizard/lib/correlation-engine.d.ts +59 -0
- package/dist/wizard/lib/correlation-engine.js +151 -0
- package/dist/wizard/lib/cost-estimator.d.ts +22 -0
- package/dist/wizard/lib/cost-estimator.js +72 -0
- package/dist/wizard/lib/cost-tracker.d.ts +27 -0
- package/dist/wizard/lib/cost-tracker.js +37 -0
- package/dist/wizard/lib/daemon-aggregator.d.ts +71 -0
- package/dist/wizard/lib/daemon-aggregator.js +204 -0
- package/dist/wizard/lib/daemon-core.d.ts +6 -0
- package/dist/wizard/lib/daemon-core.js +5 -0
- package/dist/wizard/lib/dashboard-data.d.ts +132 -0
- package/dist/wizard/lib/dashboard-data.js +336 -0
- package/dist/wizard/lib/dashboard-ws.d.ts +25 -0
- package/dist/wizard/lib/dashboard-ws.js +91 -0
- package/dist/wizard/lib/deep-current.d.ts +77 -0
- package/dist/wizard/lib/deep-current.js +234 -0
- package/dist/wizard/lib/deploy-coordinator.d.ts +40 -0
- package/dist/wizard/lib/deploy-coordinator.js +86 -0
- package/dist/wizard/lib/deploy-log.d.ts +28 -0
- package/dist/wizard/lib/deploy-log.js +52 -0
- package/dist/wizard/lib/desktop-notify.d.ts +27 -0
- package/dist/wizard/lib/desktop-notify.js +98 -0
- package/dist/wizard/lib/dns/cloudflare-dns.d.ts +35 -0
- package/dist/wizard/lib/dns/cloudflare-dns.js +216 -0
- package/dist/wizard/lib/dns/cloudflare-registrar.d.ts +31 -0
- package/dist/wizard/lib/dns/cloudflare-registrar.js +148 -0
- package/dist/wizard/lib/dns/types.d.ts +22 -0
- package/dist/wizard/lib/dns/types.js +4 -0
- package/dist/wizard/lib/document-discovery.d.ts +33 -0
- package/dist/wizard/lib/document-discovery.js +145 -0
- package/dist/wizard/lib/env-validator.d.ts +14 -0
- package/dist/wizard/lib/env-validator.js +205 -0
- package/dist/wizard/lib/env-writer.d.ts +13 -0
- package/dist/wizard/lib/env-writer.js +26 -0
- package/dist/wizard/lib/exec.d.ts +30 -0
- package/dist/wizard/lib/exec.js +52 -0
- package/dist/wizard/lib/experiment.d.ts +70 -0
- package/dist/wizard/lib/experiment.js +169 -0
- package/dist/wizard/lib/extensions.d.ts +20 -0
- package/dist/wizard/lib/extensions.js +183 -0
- package/dist/wizard/lib/financial/adapter-factory.d.ts +47 -0
- package/dist/wizard/lib/financial/adapter-factory.js +225 -0
- package/dist/wizard/lib/financial/billing/base.d.ts +6 -0
- package/dist/wizard/lib/financial/billing/base.js +1 -0
- package/dist/wizard/lib/financial/billing/google-billing.d.ts +56 -0
- package/dist/wizard/lib/financial/billing/google-billing.js +298 -0
- package/dist/wizard/lib/financial/billing/meta-billing.d.ts +54 -0
- package/dist/wizard/lib/financial/billing/meta-billing.js +243 -0
- package/dist/wizard/lib/financial/billing/tiktok-billing.d.ts +54 -0
- package/dist/wizard/lib/financial/billing/tiktok-billing.js +260 -0
- package/dist/wizard/lib/financial/campaign/base.d.ts +13 -0
- package/dist/wizard/lib/financial/campaign/base.js +1 -0
- package/dist/wizard/lib/financial/campaign/google-campaign.d.ts +42 -0
- package/dist/wizard/lib/financial/campaign/google-campaign.js +388 -0
- package/dist/wizard/lib/financial/campaign/meta-campaign.d.ts +41 -0
- package/dist/wizard/lib/financial/campaign/meta-campaign.js +311 -0
- package/dist/wizard/lib/financial/campaign/sandbox-campaign.d.ts +45 -0
- package/dist/wizard/lib/financial/campaign/sandbox-campaign.js +261 -0
- package/dist/wizard/lib/financial/campaign/tiktok-campaign.d.ts +40 -0
- package/dist/wizard/lib/financial/campaign/tiktok-campaign.js +350 -0
- package/dist/wizard/lib/financial/funding-auto.d.ts +44 -0
- package/dist/wizard/lib/financial/funding-auto.js +52 -0
- package/dist/wizard/lib/financial/funding-policy.d.ts +60 -0
- package/dist/wizard/lib/financial/funding-policy.js +179 -0
- package/dist/wizard/lib/financial/platform-planner.d.ts +47 -0
- package/dist/wizard/lib/financial/platform-planner.js +134 -0
- package/dist/wizard/lib/financial/reconciliation-engine.d.ts +78 -0
- package/dist/wizard/lib/financial/reconciliation-engine.js +193 -0
- package/dist/wizard/lib/financial/registry.d.ts +22 -0
- package/dist/wizard/lib/financial/registry.js +26 -0
- package/dist/wizard/lib/financial/reporting.d.ts +96 -0
- package/dist/wizard/lib/financial/reporting.js +198 -0
- package/dist/wizard/lib/financial/stablecoin/base.d.ts +6 -0
- package/dist/wizard/lib/financial/stablecoin/base.js +1 -0
- package/dist/wizard/lib/financial/stablecoin/circle.d.ts +54 -0
- package/dist/wizard/lib/financial/stablecoin/circle.js +367 -0
- package/dist/wizard/lib/financial/stablecoin/mercury.d.ts +24 -0
- package/dist/wizard/lib/financial/stablecoin/mercury.js +171 -0
- package/dist/wizard/lib/financial/stablecoin/sandbox-stablecoin.d.ts +47 -0
- package/dist/wizard/lib/financial/stablecoin/sandbox-stablecoin.js +202 -0
- package/dist/wizard/lib/financial/treasury-planner.d.ts +52 -0
- package/dist/wizard/lib/financial/treasury-planner.js +128 -0
- package/dist/wizard/lib/financial-core.d.ts +6 -0
- package/dist/wizard/lib/financial-core.js +5 -0
- package/dist/wizard/lib/financial-vault.d.ts +34 -0
- package/dist/wizard/lib/financial-vault.js +199 -0
- package/dist/wizard/lib/frontmatter.d.ts +30 -0
- package/dist/wizard/lib/frontmatter.js +96 -0
- package/dist/wizard/lib/gap-analysis.d.ts +37 -0
- package/dist/wizard/lib/gap-analysis.js +218 -0
- package/dist/wizard/lib/github.d.ts +22 -0
- package/dist/wizard/lib/github.js +261 -0
- package/dist/wizard/lib/headless-deploy.d.ts +14 -0
- package/dist/wizard/lib/headless-deploy.js +452 -0
- package/dist/wizard/lib/health-monitor.d.ts +15 -0
- package/dist/wizard/lib/health-monitor.js +91 -0
- package/dist/wizard/lib/health-poller.d.ts +9 -0
- package/dist/wizard/lib/health-poller.js +123 -0
- package/dist/wizard/lib/heartbeat.d.ts +15 -0
- package/dist/wizard/lib/heartbeat.js +827 -0
- package/dist/wizard/lib/http-helpers.d.ts +9 -0
- package/dist/wizard/lib/http-helpers.js +24 -0
- package/dist/wizard/lib/image-gen.d.ts +56 -0
- package/dist/wizard/lib/image-gen.js +159 -0
- package/dist/wizard/lib/instance-sizing.d.ts +26 -0
- package/dist/wizard/lib/instance-sizing.js +51 -0
- package/dist/wizard/lib/kongo/analytics.d.ts +29 -0
- package/dist/wizard/lib/kongo/analytics.js +179 -0
- package/dist/wizard/lib/kongo/campaigns.d.ts +52 -0
- package/dist/wizard/lib/kongo/campaigns.js +91 -0
- package/dist/wizard/lib/kongo/client.d.ts +58 -0
- package/dist/wizard/lib/kongo/client.js +221 -0
- package/dist/wizard/lib/kongo/jobs.d.ts +57 -0
- package/dist/wizard/lib/kongo/jobs.js +122 -0
- package/dist/wizard/lib/kongo/pages.d.ts +60 -0
- package/dist/wizard/lib/kongo/pages.js +150 -0
- package/dist/wizard/lib/kongo/provisioner.d.ts +64 -0
- package/dist/wizard/lib/kongo/provisioner.js +116 -0
- package/dist/wizard/lib/kongo/seed.d.ts +49 -0
- package/dist/wizard/lib/kongo/seed.js +237 -0
- package/dist/wizard/lib/kongo/types.d.ts +323 -0
- package/dist/wizard/lib/kongo/types.js +11 -0
- package/dist/wizard/lib/kongo/variants.d.ts +57 -0
- package/dist/wizard/lib/kongo/variants.js +88 -0
- package/dist/wizard/lib/kongo/webhooks.d.ts +41 -0
- package/dist/wizard/lib/kongo/webhooks.js +112 -0
- package/dist/wizard/lib/marker.d.ts +28 -0
- package/dist/wizard/lib/marker.js +79 -0
- package/dist/wizard/lib/migrator.d.ts +35 -0
- package/dist/wizard/lib/migrator.js +190 -0
- package/dist/wizard/lib/natural-language-deploy.d.ts +30 -0
- package/dist/wizard/lib/natural-language-deploy.js +186 -0
- package/dist/wizard/lib/network.d.ts +22 -0
- package/dist/wizard/lib/network.js +72 -0
- package/dist/wizard/lib/oauth-core.d.ts +6 -0
- package/dist/wizard/lib/oauth-core.js +5 -0
- package/dist/wizard/lib/open-browser.d.ts +1 -0
- package/dist/wizard/lib/open-browser.js +26 -0
- package/dist/wizard/lib/patterns/ad-billing-adapter.d.ts +209 -0
- package/dist/wizard/lib/patterns/ad-billing-adapter.js +269 -0
- package/dist/wizard/lib/patterns/ad-platform-adapter.d.ts +200 -0
- package/dist/wizard/lib/patterns/ad-platform-adapter.js +212 -0
- package/dist/wizard/lib/patterns/daemon-process.d.ts +88 -0
- package/dist/wizard/lib/patterns/daemon-process.js +271 -0
- package/dist/wizard/lib/patterns/financial-transaction.d.ts +161 -0
- package/dist/wizard/lib/patterns/financial-transaction.js +132 -0
- package/dist/wizard/lib/patterns/funding-plan.d.ts +136 -0
- package/dist/wizard/lib/patterns/funding-plan.js +200 -0
- package/dist/wizard/lib/patterns/oauth-token-lifecycle.d.ts +94 -0
- package/dist/wizard/lib/patterns/oauth-token-lifecycle.js +139 -0
- package/dist/wizard/lib/patterns/outbound-rate-limiter.d.ts +67 -0
- package/dist/wizard/lib/patterns/outbound-rate-limiter.js +216 -0
- package/dist/wizard/lib/patterns/revenue-source-adapter.d.ts +96 -0
- package/dist/wizard/lib/patterns/revenue-source-adapter.js +182 -0
- package/dist/wizard/lib/patterns/stablecoin-adapter.d.ts +218 -0
- package/dist/wizard/lib/patterns/stablecoin-adapter.js +264 -0
- package/dist/wizard/lib/prd-validator.d.ts +39 -0
- package/dist/wizard/lib/prd-validator.js +137 -0
- package/dist/wizard/lib/project-init.d.ts +24 -0
- package/dist/wizard/lib/project-init.js +193 -0
- package/dist/wizard/lib/project-registry.d.ts +86 -0
- package/dist/wizard/lib/project-registry.js +359 -0
- package/dist/wizard/lib/provision-manifest.d.ts +44 -0
- package/dist/wizard/lib/provision-manifest.js +164 -0
- package/dist/wizard/lib/provisioner-registry.d.ts +15 -0
- package/dist/wizard/lib/provisioner-registry.js +34 -0
- package/dist/wizard/lib/provisioners/aws-vps.d.ts +6 -0
- package/dist/wizard/lib/provisioners/aws-vps.js +643 -0
- package/dist/wizard/lib/provisioners/cloudflare.d.ts +6 -0
- package/dist/wizard/lib/provisioners/cloudflare.js +300 -0
- package/dist/wizard/lib/provisioners/docker.d.ts +6 -0
- package/dist/wizard/lib/provisioners/docker.js +75 -0
- package/dist/wizard/lib/provisioners/http-client.d.ts +20 -0
- package/dist/wizard/lib/provisioners/http-client.js +79 -0
- package/dist/wizard/lib/provisioners/railway.d.ts +6 -0
- package/dist/wizard/lib/provisioners/railway.js +413 -0
- package/dist/wizard/lib/provisioners/scripts/caddyfile.d.ts +10 -0
- package/dist/wizard/lib/provisioners/scripts/caddyfile.js +54 -0
- package/dist/wizard/lib/provisioners/scripts/deploy-vps.d.ts +10 -0
- package/dist/wizard/lib/provisioners/scripts/deploy-vps.js +112 -0
- package/dist/wizard/lib/provisioners/scripts/docker-compose.d.ts +11 -0
- package/dist/wizard/lib/provisioners/scripts/docker-compose.js +91 -0
- package/dist/wizard/lib/provisioners/scripts/dockerfile.d.ts +5 -0
- package/dist/wizard/lib/provisioners/scripts/dockerfile.js +185 -0
- package/dist/wizard/lib/provisioners/scripts/ecosystem-config.d.ts +10 -0
- package/dist/wizard/lib/provisioners/scripts/ecosystem-config.js +36 -0
- package/dist/wizard/lib/provisioners/scripts/provision-vps.d.ts +14 -0
- package/dist/wizard/lib/provisioners/scripts/provision-vps.js +202 -0
- package/dist/wizard/lib/provisioners/scripts/rollback-vps.d.ts +10 -0
- package/dist/wizard/lib/provisioners/scripts/rollback-vps.js +67 -0
- package/dist/wizard/lib/provisioners/self-deploy.d.ts +41 -0
- package/dist/wizard/lib/provisioners/self-deploy.js +185 -0
- package/dist/wizard/lib/provisioners/static-s3.d.ts +6 -0
- package/dist/wizard/lib/provisioners/static-s3.js +235 -0
- package/dist/wizard/lib/provisioners/types.d.ts +40 -0
- package/dist/wizard/lib/provisioners/types.js +4 -0
- package/dist/wizard/lib/provisioners/vercel.d.ts +6 -0
- package/dist/wizard/lib/provisioners/vercel.js +287 -0
- package/dist/wizard/lib/pty-manager.d.ts +42 -0
- package/dist/wizard/lib/pty-manager.js +231 -0
- package/dist/wizard/lib/rate-limiter-core.d.ts +5 -0
- package/dist/wizard/lib/rate-limiter-core.js +5 -0
- package/dist/wizard/lib/reconciliation.d.ts +43 -0
- package/dist/wizard/lib/reconciliation.js +173 -0
- package/dist/wizard/lib/revenue-types.d.ts +5 -0
- package/dist/wizard/lib/revenue-types.js +1 -0
- package/dist/wizard/lib/route-optimizer.d.ts +28 -0
- package/dist/wizard/lib/route-optimizer.js +93 -0
- package/dist/wizard/lib/s3-deploy.d.ts +19 -0
- package/dist/wizard/lib/s3-deploy.js +156 -0
- package/dist/wizard/lib/safety-tiers.d.ts +76 -0
- package/dist/wizard/lib/safety-tiers.js +134 -0
- package/dist/wizard/lib/sentry-generator.d.ts +15 -0
- package/dist/wizard/lib/sentry-generator.js +116 -0
- package/dist/wizard/lib/server-config.d.ts +13 -0
- package/dist/wizard/lib/server-config.js +23 -0
- package/dist/wizard/lib/service-install.d.ts +18 -0
- package/dist/wizard/lib/service-install.js +182 -0
- package/dist/wizard/lib/site-scanner.d.ts +80 -0
- package/dist/wizard/lib/site-scanner.js +262 -0
- package/dist/wizard/lib/ssh-deploy.d.ts +25 -0
- package/dist/wizard/lib/ssh-deploy.js +225 -0
- package/dist/wizard/lib/templates.d.ts +24 -0
- package/dist/wizard/lib/templates.js +219 -0
- package/dist/wizard/lib/totp.d.ts +35 -0
- package/dist/wizard/lib/totp.js +276 -0
- package/dist/wizard/lib/tower-auth.d.ts +43 -0
- package/dist/wizard/lib/tower-auth.js +352 -0
- package/dist/wizard/lib/tower-rate-limit.d.ts +14 -0
- package/dist/wizard/lib/tower-rate-limit.js +61 -0
- package/dist/wizard/lib/tower-session.d.ts +28 -0
- package/dist/wizard/lib/tower-session.js +119 -0
- package/dist/wizard/lib/treasury-backup.d.ts +23 -0
- package/dist/wizard/lib/treasury-backup.js +126 -0
- package/dist/wizard/lib/treasury-heartbeat.d.ts +82 -0
- package/dist/wizard/lib/treasury-heartbeat.js +1104 -0
- package/dist/wizard/lib/updater.d.ts +29 -0
- package/dist/wizard/lib/updater.js +190 -0
- package/dist/wizard/lib/user-manager.d.ts +39 -0
- package/dist/wizard/lib/user-manager.js +182 -0
- package/dist/wizard/lib/vault.d.ts +26 -0
- package/dist/wizard/lib/vault.js +161 -0
- package/dist/wizard/router.d.ts +5 -0
- package/dist/wizard/router.js +15 -0
- package/dist/wizard/server.d.ts +18 -0
- package/dist/wizard/server.js +436 -0
- package/package.json +59 -0
|
@@ -0,0 +1,827 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Heartbeat Daemon — The single-writer for all financial state (ADR-1).
|
|
3
|
+
*
|
|
4
|
+
* This module implements the heartbeat daemon: a background Node.js process
|
|
5
|
+
* that owns all financial state mutations. The CLI and Danger Room are clients
|
|
6
|
+
* that communicate via the Unix domain socket API.
|
|
7
|
+
*
|
|
8
|
+
* PRD Reference: §9.7, §9.18, §9.19.2, §9.20.4, §9.20.11
|
|
9
|
+
*/
|
|
10
|
+
import { join } from 'node:path';
|
|
11
|
+
import { homedir } from 'node:os';
|
|
12
|
+
import { existsSync } from 'node:fs';
|
|
13
|
+
import { readFile, appendFile, mkdir } from 'node:fs/promises';
|
|
14
|
+
import { writePidFile, checkStalePid, generateSessionToken, createSocketServer, startSocketServer, writeState, setupSignalHandlers, JobScheduler, createLogger, STATE_FILE, } from './daemon-core.js';
|
|
15
|
+
import { financialVaultGet, financialVaultLock, financialVaultUnlock } from './financial-vault.js';
|
|
16
|
+
import { totpVerify, totpSessionInvalidate } from './totp.js';
|
|
17
|
+
import { classifyTier } from './safety-tiers.js';
|
|
18
|
+
import { needsRefresh, handleRefreshFailure, tokenVaultKey, deserializeTokens, } from './oauth-core.js';
|
|
19
|
+
import { appendToLog, atomicWrite, SPEND_LOG, REVENUE_LOG, TREASURY_DIR } from './financial-core.js';
|
|
20
|
+
import { registerTreasuryJobs, handleTreasuryRequest, executeTreasuryFreeze, getTreasuryStateSnapshot, isStablecoinConfigured, } from './treasury-heartbeat.js';
|
|
21
|
+
import { getCampaignAdapter } from './financial/adapter-factory.js';
|
|
22
|
+
import { transition } from './campaign-state-machine.js';
|
|
23
|
+
const PENDING_OPS = join(TREASURY_DIR, 'pending-ops.jsonl');
|
|
24
|
+
const CAMPAIGNS_DIR = join(TREASURY_DIR, 'campaigns');
|
|
25
|
+
const VOIDFORGE_DIR = join(homedir(), '.voidforge');
|
|
26
|
+
// ── Hash Chain Helper ────────────────────────────────────
|
|
27
|
+
// Reads the last line of a JSONL log file and extracts the hash field.
|
|
28
|
+
// Returns '0' (genesis hash) if the file is empty or does not exist.
|
|
29
|
+
async function getLastLogHash(logPath) {
|
|
30
|
+
try {
|
|
31
|
+
const content = await readFile(logPath, 'utf-8');
|
|
32
|
+
const lines = content.trim().split('\n').filter(Boolean);
|
|
33
|
+
if (lines.length === 0)
|
|
34
|
+
return '0';
|
|
35
|
+
const lastEntry = JSON.parse(lines[lines.length - 1]);
|
|
36
|
+
return lastEntry.hash ?? '0';
|
|
37
|
+
}
|
|
38
|
+
catch {
|
|
39
|
+
// File does not exist or is unreadable — genesis hash
|
|
40
|
+
return '0';
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
// ── Daemon State ──────────────────────────────────────
|
|
44
|
+
let daemonState = 'starting';
|
|
45
|
+
let vaultKey = null; // Vault password held in memory
|
|
46
|
+
let sessionTokenState = null;
|
|
47
|
+
let eventId = 0;
|
|
48
|
+
const logger = createLogger(join(VOIDFORGE_DIR, 'heartbeat.log'));
|
|
49
|
+
const daemonStartedAt = new Date().toISOString(); // Store once at module level (VG-R1-001)
|
|
50
|
+
// Platform state tracking
|
|
51
|
+
const platformFailures = {};
|
|
52
|
+
const platformHealth = {};
|
|
53
|
+
// ── Socket API Request Handler (§9.20.11) ─────────────
|
|
54
|
+
async function handleRequest(method, path, body, auth) {
|
|
55
|
+
// All requests require session token
|
|
56
|
+
if (!auth.hasToken) {
|
|
57
|
+
return { status: 401, body: { ok: false, error: 'Session token required' } };
|
|
58
|
+
}
|
|
59
|
+
// SEC-001 + R4-MAUL-001: Verify vault password with HMAC comparison (constant-time
|
|
60
|
+
// regardless of input length — no length leak unlike timingSafeEqual's length check)
|
|
61
|
+
let vaultVerified = false;
|
|
62
|
+
if (auth.vaultPassword && vaultKey) {
|
|
63
|
+
const { createHmac, timingSafeEqual } = await import('node:crypto');
|
|
64
|
+
const HMAC_KEY = 'voidforge-vault-password-comparison-v1';
|
|
65
|
+
const providedMac = createHmac('sha256', HMAC_KEY).update(auth.vaultPassword).digest();
|
|
66
|
+
const expectedMac = createHmac('sha256', HMAC_KEY).update(vaultKey).digest();
|
|
67
|
+
vaultVerified = timingSafeEqual(providedMac, expectedMac);
|
|
68
|
+
}
|
|
69
|
+
// SEC-001: Verify TOTP code (not just presence)
|
|
70
|
+
let totpVerified = false;
|
|
71
|
+
if (auth.totpCode) {
|
|
72
|
+
try {
|
|
73
|
+
totpVerified = await totpVerify(auth.totpCode);
|
|
74
|
+
}
|
|
75
|
+
catch { /* TOTP not configured — treat as false */ }
|
|
76
|
+
}
|
|
77
|
+
// ── Treasury routes (delegated to treasury-heartbeat module) ──
|
|
78
|
+
const treasuryFreeze = async (reason) => {
|
|
79
|
+
await executeTreasuryFreeze(reason, logger);
|
|
80
|
+
daemonState = 'degraded';
|
|
81
|
+
eventId++;
|
|
82
|
+
await writeCurrentState();
|
|
83
|
+
};
|
|
84
|
+
if (path.startsWith('/treasury/')) {
|
|
85
|
+
const treasuryResult = await handleTreasuryRequest(method, path, body, { vaultVerified, totpVerified }, logger, treasuryFreeze, vaultKey);
|
|
86
|
+
if (treasuryResult) {
|
|
87
|
+
eventId++;
|
|
88
|
+
return treasuryResult;
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
// ── Read operations ──────────────────
|
|
92
|
+
if (method === 'GET') {
|
|
93
|
+
if (path === '/status') {
|
|
94
|
+
return { status: 200, body: { ok: true, data: await buildStateSnapshot() } };
|
|
95
|
+
}
|
|
96
|
+
if (path === '/campaigns') {
|
|
97
|
+
return { status: 200, body: { ok: true, data: await readCampaigns() } };
|
|
98
|
+
}
|
|
99
|
+
if (path === '/treasury') {
|
|
100
|
+
return { status: 200, body: { ok: true, data: await readTreasurySummary() } };
|
|
101
|
+
}
|
|
102
|
+
return { status: 404, body: { ok: false, error: 'Unknown endpoint' } };
|
|
103
|
+
}
|
|
104
|
+
// ── Write operations ─────────────────
|
|
105
|
+
if (method === 'POST') {
|
|
106
|
+
// Freeze — low friction, session token only (§9.18)
|
|
107
|
+
if (path === '/freeze') {
|
|
108
|
+
return await handleFreeze();
|
|
109
|
+
}
|
|
110
|
+
// Unfreeze — requires vault + TOTP (§9.18)
|
|
111
|
+
if (path === '/unfreeze') {
|
|
112
|
+
if (!vaultVerified || !totpVerified) {
|
|
113
|
+
return { status: 403, body: { ok: false, error: 'Unfreeze requires valid vault password + TOTP code' } };
|
|
114
|
+
}
|
|
115
|
+
return await handleUnfreeze();
|
|
116
|
+
}
|
|
117
|
+
// Vault unlock — re-enter vault password after timeout
|
|
118
|
+
if (path === '/unlock') {
|
|
119
|
+
return await handleUnlock(body);
|
|
120
|
+
}
|
|
121
|
+
// Campaign pause — session token only (protective action)
|
|
122
|
+
if (path.match(/^\/campaigns\/[^/]+\/pause$/)) {
|
|
123
|
+
const id = path.split('/')[2];
|
|
124
|
+
return await handleCampaignPause(id);
|
|
125
|
+
}
|
|
126
|
+
// Campaign creative update — session token only for non-URL changes (§9.20.11)
|
|
127
|
+
if (path.match(/^\/campaigns\/[^/]+\/creative$/)) {
|
|
128
|
+
const id = path.split('/')[2];
|
|
129
|
+
return await handleCreativeUpdate(id, body);
|
|
130
|
+
}
|
|
131
|
+
// Campaign resume — requires vault password
|
|
132
|
+
if (path.match(/^\/campaigns\/[^/]+\/resume$/)) {
|
|
133
|
+
if (!vaultVerified) {
|
|
134
|
+
return { status: 403, body: { ok: false, error: 'Resume requires valid vault password' } };
|
|
135
|
+
}
|
|
136
|
+
const id = path.split('/')[2];
|
|
137
|
+
return await handleCampaignResume(id);
|
|
138
|
+
}
|
|
139
|
+
// Campaign launch — requires vault password + safety tier check (SEC-004)
|
|
140
|
+
if (path === '/campaigns/launch') {
|
|
141
|
+
if (!vaultVerified) {
|
|
142
|
+
return { status: 403, body: { ok: false, error: 'Campaign launch requires valid vault password' } };
|
|
143
|
+
}
|
|
144
|
+
return await handleCampaignLaunch(body);
|
|
145
|
+
}
|
|
146
|
+
// Budget modification — requires vault password + safety tier check (SEC-004)
|
|
147
|
+
if (path === '/budget') {
|
|
148
|
+
if (!vaultVerified) {
|
|
149
|
+
return { status: 403, body: { ok: false, error: 'Budget changes require valid vault password' } };
|
|
150
|
+
}
|
|
151
|
+
return await handleBudgetChange(body);
|
|
152
|
+
}
|
|
153
|
+
// Manual reconciliation
|
|
154
|
+
if (path === '/reconcile') {
|
|
155
|
+
return await handleReconcile();
|
|
156
|
+
}
|
|
157
|
+
return { status: 404, body: { ok: false, error: 'Unknown endpoint' } };
|
|
158
|
+
}
|
|
159
|
+
return { status: 405, body: { ok: false, error: 'Method not allowed' } };
|
|
160
|
+
}
|
|
161
|
+
/** Validate campaign ID — must be UUID-like (alphanumeric + hyphens). Prevents path traversal. */
|
|
162
|
+
function validateCampaignId(id) {
|
|
163
|
+
return /^[a-zA-Z0-9_-]{1,128}$/.test(id);
|
|
164
|
+
}
|
|
165
|
+
async function writeCampaignRecord(record) {
|
|
166
|
+
if (!validateCampaignId(record.campaignId)) {
|
|
167
|
+
throw new Error(`Invalid campaign ID format: ${record.campaignId.slice(0, 20)}`);
|
|
168
|
+
}
|
|
169
|
+
await mkdir(CAMPAIGNS_DIR, { recursive: true });
|
|
170
|
+
const filePath = join(CAMPAIGNS_DIR, `${record.campaignId}.json`);
|
|
171
|
+
await atomicWrite(filePath, JSON.stringify(record, null, 2));
|
|
172
|
+
}
|
|
173
|
+
async function readCampaignRecord(campaignId) {
|
|
174
|
+
if (!validateCampaignId(campaignId))
|
|
175
|
+
return null;
|
|
176
|
+
const filePath = join(CAMPAIGNS_DIR, `${campaignId}.json`);
|
|
177
|
+
try {
|
|
178
|
+
const content = await readFile(filePath, 'utf-8');
|
|
179
|
+
return JSON.parse(content);
|
|
180
|
+
}
|
|
181
|
+
catch {
|
|
182
|
+
return null;
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
async function getActiveCampaignRecords() {
|
|
186
|
+
const campaigns = await readCampaigns();
|
|
187
|
+
return campaigns.filter(c => c.status === 'active');
|
|
188
|
+
}
|
|
189
|
+
async function getSuspendedCampaignRecords() {
|
|
190
|
+
const campaigns = await readCampaigns();
|
|
191
|
+
return campaigns.filter(c => c.status === 'suspended');
|
|
192
|
+
}
|
|
193
|
+
async function getAdapterForPlatform(platform) {
|
|
194
|
+
return getCampaignAdapter(platform, vaultKey, logger);
|
|
195
|
+
}
|
|
196
|
+
// ── Command Handlers ──────────────────────────────────
|
|
197
|
+
async function handleFreeze() {
|
|
198
|
+
logger.log('FREEZE command received — pausing all active campaigns');
|
|
199
|
+
const activeCampaigns = await getActiveCampaignRecords();
|
|
200
|
+
let pausedCount = 0;
|
|
201
|
+
const errors = [];
|
|
202
|
+
for (const campaign of activeCampaigns) {
|
|
203
|
+
try {
|
|
204
|
+
const adapter = await getAdapterForPlatform(campaign.platform);
|
|
205
|
+
await adapter.pauseCampaign(campaign.externalId);
|
|
206
|
+
const event = transition(campaign.status, 'suspended', 'cli', 'freeze');
|
|
207
|
+
campaign.status = event.newStatus;
|
|
208
|
+
campaign.updatedAt = new Date().toISOString();
|
|
209
|
+
await writeCampaignRecord(campaign);
|
|
210
|
+
pausedCount++;
|
|
211
|
+
}
|
|
212
|
+
catch (err) {
|
|
213
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
214
|
+
errors.push(`${campaign.campaignId}: ${msg}`);
|
|
215
|
+
logger.log(`Freeze: failed to pause campaign ${campaign.campaignId}: ${msg}`);
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
daemonState = 'degraded';
|
|
219
|
+
eventId++;
|
|
220
|
+
await writeCurrentState();
|
|
221
|
+
const allPaused = errors.length === 0;
|
|
222
|
+
logger.log(`Freeze complete: ${pausedCount}/${activeCampaigns.length} campaigns paused${allPaused ? '' : ` (${errors.length} failures)`}`);
|
|
223
|
+
return {
|
|
224
|
+
status: allPaused ? 200 : 207,
|
|
225
|
+
body: {
|
|
226
|
+
ok: allPaused,
|
|
227
|
+
message: allPaused
|
|
228
|
+
? `Freeze complete: ${pausedCount} campaigns paused`
|
|
229
|
+
: `Freeze partial: ${pausedCount}/${activeCampaigns.length} campaigns paused, ${errors.length} failed`,
|
|
230
|
+
pausedCount,
|
|
231
|
+
totalCampaigns: activeCampaigns.length,
|
|
232
|
+
errors: errors.length > 0 ? errors : undefined,
|
|
233
|
+
},
|
|
234
|
+
};
|
|
235
|
+
}
|
|
236
|
+
async function handleUnfreeze() {
|
|
237
|
+
logger.log('UNFREEZE command received — resuming suspended campaigns');
|
|
238
|
+
const suspendedCampaigns = await getSuspendedCampaignRecords();
|
|
239
|
+
let resumedCount = 0;
|
|
240
|
+
const errors = [];
|
|
241
|
+
for (const campaign of suspendedCampaigns) {
|
|
242
|
+
try {
|
|
243
|
+
const adapter = await getAdapterForPlatform(campaign.platform);
|
|
244
|
+
await adapter.resumeCampaign(campaign.externalId);
|
|
245
|
+
const event = transition(campaign.status, 'active', 'cli', 'unfreeze');
|
|
246
|
+
campaign.status = event.newStatus;
|
|
247
|
+
campaign.updatedAt = new Date().toISOString();
|
|
248
|
+
await writeCampaignRecord(campaign);
|
|
249
|
+
resumedCount++;
|
|
250
|
+
}
|
|
251
|
+
catch (err) {
|
|
252
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
253
|
+
errors.push(`${campaign.campaignId}: ${msg}`);
|
|
254
|
+
logger.log(`Unfreeze: failed to resume campaign ${campaign.campaignId}: ${msg}`);
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
daemonState = 'healthy';
|
|
258
|
+
eventId++;
|
|
259
|
+
await writeCurrentState();
|
|
260
|
+
logger.log(`Unfreeze complete: ${resumedCount}/${suspendedCampaigns.length} campaigns resumed`);
|
|
261
|
+
return {
|
|
262
|
+
status: 200,
|
|
263
|
+
body: {
|
|
264
|
+
ok: true,
|
|
265
|
+
message: `Spending resumed: ${resumedCount} campaigns unfrozen`,
|
|
266
|
+
resumedCount,
|
|
267
|
+
errors: errors.length > 0 ? errors : undefined,
|
|
268
|
+
},
|
|
269
|
+
};
|
|
270
|
+
}
|
|
271
|
+
async function handleUnlock(body) {
|
|
272
|
+
if (!body.password) {
|
|
273
|
+
return { status: 400, body: { ok: false, error: 'Password required' } };
|
|
274
|
+
}
|
|
275
|
+
// SEC-002: Verify the password can actually decrypt the vault before accepting
|
|
276
|
+
const valid = await financialVaultUnlock(body.password);
|
|
277
|
+
if (!valid) {
|
|
278
|
+
logger.log('Vault unlock failed — wrong password');
|
|
279
|
+
return { status: 403, body: { ok: false, error: 'Invalid vault password' } };
|
|
280
|
+
}
|
|
281
|
+
vaultKey = body.password;
|
|
282
|
+
if (daemonState === 'degraded')
|
|
283
|
+
daemonState = 'healthy';
|
|
284
|
+
logger.log('Vault unlocked');
|
|
285
|
+
eventId++;
|
|
286
|
+
await writeCurrentState();
|
|
287
|
+
return { status: 200, body: { ok: true, message: 'Vault session renewed' } };
|
|
288
|
+
}
|
|
289
|
+
async function handleCampaignPause(id) {
|
|
290
|
+
logger.log(`Campaign ${id} pause requested`);
|
|
291
|
+
const record = await readCampaignRecord(id);
|
|
292
|
+
if (!record) {
|
|
293
|
+
return { status: 404, body: { ok: false, error: `Campaign not found: ${id}` } };
|
|
294
|
+
}
|
|
295
|
+
try {
|
|
296
|
+
const adapter = await getAdapterForPlatform(record.platform);
|
|
297
|
+
await adapter.pauseCampaign(record.externalId);
|
|
298
|
+
const event = transition(record.status, 'paused', 'cli', 'user_paused');
|
|
299
|
+
record.status = event.newStatus;
|
|
300
|
+
record.updatedAt = new Date().toISOString();
|
|
301
|
+
await writeCampaignRecord(record);
|
|
302
|
+
eventId++;
|
|
303
|
+
await appendToLog(SPEND_LOG, { type: 'campaign_pause', campaignId: id, timestamp: record.updatedAt }, await getLastLogHash(SPEND_LOG));
|
|
304
|
+
logger.log(`Campaign ${id} paused on ${record.platform}`);
|
|
305
|
+
return { status: 200, body: { ok: true, campaignId: id, status: 'paused' } };
|
|
306
|
+
}
|
|
307
|
+
catch (err) {
|
|
308
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
309
|
+
logger.log(`Campaign ${id} pause failed: ${msg}`);
|
|
310
|
+
return { status: 500, body: { ok: false, error: `Pause failed: ${msg}` } };
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
async function handleCampaignResume(id) {
|
|
314
|
+
logger.log(`Campaign ${id} resume requested`);
|
|
315
|
+
const record = await readCampaignRecord(id);
|
|
316
|
+
if (!record) {
|
|
317
|
+
return { status: 404, body: { ok: false, error: `Campaign not found: ${id}` } };
|
|
318
|
+
}
|
|
319
|
+
try {
|
|
320
|
+
const adapter = await getAdapterForPlatform(record.platform);
|
|
321
|
+
await adapter.resumeCampaign(record.externalId);
|
|
322
|
+
const event = transition(record.status, 'active', 'cli', 'user_resumed');
|
|
323
|
+
record.status = event.newStatus;
|
|
324
|
+
record.updatedAt = new Date().toISOString();
|
|
325
|
+
await writeCampaignRecord(record);
|
|
326
|
+
eventId++;
|
|
327
|
+
await appendToLog(SPEND_LOG, { type: 'campaign_resume', campaignId: id, timestamp: record.updatedAt }, await getLastLogHash(SPEND_LOG));
|
|
328
|
+
logger.log(`Campaign ${id} resumed on ${record.platform}`);
|
|
329
|
+
return { status: 200, body: { ok: true, campaignId: id, status: 'active' } };
|
|
330
|
+
}
|
|
331
|
+
catch (err) {
|
|
332
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
333
|
+
logger.log(`Campaign ${id} resume failed: ${msg}`);
|
|
334
|
+
return { status: 500, body: { ok: false, error: `Resume failed: ${msg}` } };
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
async function handleCampaignLaunch(body) {
|
|
338
|
+
logger.log('Campaign launch requested');
|
|
339
|
+
const config = body;
|
|
340
|
+
// Validate required fields
|
|
341
|
+
if (!config.name || !config.platform || !config.dailyBudgetCents || !config.idempotencyKey) {
|
|
342
|
+
return { status: 400, body: { ok: false, error: 'Missing required fields: name, platform, dailyBudgetCents, idempotencyKey' } };
|
|
343
|
+
}
|
|
344
|
+
// Budget validation — must be positive finite integer
|
|
345
|
+
if (!Number.isFinite(config.dailyBudgetCents) || config.dailyBudgetCents <= 0 || !Number.isInteger(config.dailyBudgetCents)) {
|
|
346
|
+
return { status: 400, body: { ok: false, error: 'dailyBudgetCents must be a positive integer' } };
|
|
347
|
+
}
|
|
348
|
+
// Safety tier check (SEC-004): classify budget against aggregate of active campaigns
|
|
349
|
+
const activeCampaigns = await getActiveCampaignRecords();
|
|
350
|
+
const aggregateDailySpend = activeCampaigns.reduce((sum, c) => (sum + (c.dailyBudgetCents || 0)), 0);
|
|
351
|
+
const tierResult = classifyTier(config.dailyBudgetCents, aggregateDailySpend);
|
|
352
|
+
if (tierResult.tier !== 'auto_approve') {
|
|
353
|
+
logger.log(`Campaign launch: budget $${(config.dailyBudgetCents / 100).toFixed(2)} + aggregate $${(aggregateDailySpend / 100).toFixed(2)}/day → ${tierResult.tier} (${tierResult.reason})`);
|
|
354
|
+
if (tierResult.requiresTotp) {
|
|
355
|
+
return { status: 403, body: { ok: false, error: `Budget tier: ${tierResult.tier}. ${tierResult.reason}. Requires TOTP.` } };
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
try {
|
|
359
|
+
const adapter = await getAdapterForPlatform(config.platform);
|
|
360
|
+
const campaignConfig = {
|
|
361
|
+
name: config.name,
|
|
362
|
+
platform: config.platform,
|
|
363
|
+
objective: config.objective ?? 'traffic',
|
|
364
|
+
dailyBudget: config.dailyBudgetCents,
|
|
365
|
+
targeting: config.targeting ?? { audiences: [], locations: [] },
|
|
366
|
+
creative: config.creative ?? { headlines: [], descriptions: [], callToAction: '', landingUrl: '' },
|
|
367
|
+
idempotencyKey: config.idempotencyKey,
|
|
368
|
+
complianceStatus: 'passed',
|
|
369
|
+
};
|
|
370
|
+
// WAL entry before platform call
|
|
371
|
+
await writePendingOp({
|
|
372
|
+
intentId: config.idempotencyKey,
|
|
373
|
+
operation: 'campaign_launch',
|
|
374
|
+
platform: config.platform,
|
|
375
|
+
params: campaignConfig,
|
|
376
|
+
status: 'pending',
|
|
377
|
+
createdAt: new Date().toISOString(),
|
|
378
|
+
});
|
|
379
|
+
const result = await adapter.createCampaign(campaignConfig);
|
|
380
|
+
// Transition state: creating → active (or pending_review → creating based on platform)
|
|
381
|
+
const campaignId = config.idempotencyKey;
|
|
382
|
+
const now = new Date().toISOString();
|
|
383
|
+
const record = {
|
|
384
|
+
campaignId,
|
|
385
|
+
externalId: result.externalId,
|
|
386
|
+
platform: config.platform,
|
|
387
|
+
status: result.status === 'pending_review' ? 'pending_approval' : 'active',
|
|
388
|
+
name: config.name,
|
|
389
|
+
dailyBudgetCents: config.dailyBudgetCents,
|
|
390
|
+
createdAt: now,
|
|
391
|
+
updatedAt: now,
|
|
392
|
+
};
|
|
393
|
+
await writeCampaignRecord(record);
|
|
394
|
+
// Log spend event
|
|
395
|
+
await appendToLog(SPEND_LOG, {
|
|
396
|
+
type: 'campaign_launch',
|
|
397
|
+
campaignId,
|
|
398
|
+
externalId: result.externalId,
|
|
399
|
+
platform: config.platform,
|
|
400
|
+
dailyBudgetCents: config.dailyBudgetCents,
|
|
401
|
+
timestamp: now,
|
|
402
|
+
}, await getLastLogHash(SPEND_LOG));
|
|
403
|
+
eventId++;
|
|
404
|
+
logger.log(`Campaign launched: ${campaignId} → ${result.externalId} on ${config.platform} (status: ${record.status})`);
|
|
405
|
+
return {
|
|
406
|
+
status: 200,
|
|
407
|
+
body: {
|
|
408
|
+
ok: true,
|
|
409
|
+
campaignId,
|
|
410
|
+
externalId: result.externalId,
|
|
411
|
+
platform: config.platform,
|
|
412
|
+
status: record.status,
|
|
413
|
+
dashboardUrl: result.dashboardUrl,
|
|
414
|
+
},
|
|
415
|
+
};
|
|
416
|
+
}
|
|
417
|
+
catch (err) {
|
|
418
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
419
|
+
logger.log(`Campaign launch failed: ${msg}`);
|
|
420
|
+
return { status: 500, body: { ok: false, error: `Launch failed: ${msg}` } };
|
|
421
|
+
}
|
|
422
|
+
}
|
|
423
|
+
async function handleBudgetChange(body) {
|
|
424
|
+
logger.log('Budget change requested');
|
|
425
|
+
const params = body;
|
|
426
|
+
if (!params.campaignId || params.newBudgetCents === undefined) {
|
|
427
|
+
return { status: 400, body: { ok: false, error: 'Missing required fields: campaignId, newBudgetCents' } };
|
|
428
|
+
}
|
|
429
|
+
// Budget validation — must be positive finite integer
|
|
430
|
+
if (!Number.isFinite(params.newBudgetCents) || params.newBudgetCents <= 0 || !Number.isInteger(params.newBudgetCents)) {
|
|
431
|
+
return { status: 400, body: { ok: false, error: 'newBudgetCents must be a positive integer' } };
|
|
432
|
+
}
|
|
433
|
+
// Safety tier check BEFORE WAL (SEC-004) — consider aggregate of active campaigns
|
|
434
|
+
const activeBudgets = await getActiveCampaignRecords();
|
|
435
|
+
const currentAggregate = activeBudgets.reduce((sum, c) => (sum + (c.dailyBudgetCents || 0)), 0);
|
|
436
|
+
const tierResult = classifyTier(params.newBudgetCents, currentAggregate);
|
|
437
|
+
if (tierResult.requiresTotp) {
|
|
438
|
+
return { status: 403, body: { ok: false, error: `Budget tier: ${tierResult.tier}. ${tierResult.reason}. Requires TOTP.` } };
|
|
439
|
+
}
|
|
440
|
+
// WAL entry before platform call (ADR-3) — only after tier check passes
|
|
441
|
+
await writePendingOp({
|
|
442
|
+
intentId: `budget_${params.campaignId}_${Date.now()}`,
|
|
443
|
+
operation: 'budget_change',
|
|
444
|
+
platform: 'unknown',
|
|
445
|
+
params,
|
|
446
|
+
status: 'pending',
|
|
447
|
+
createdAt: new Date().toISOString(),
|
|
448
|
+
});
|
|
449
|
+
const record = await readCampaignRecord(params.campaignId);
|
|
450
|
+
if (!record) {
|
|
451
|
+
return { status: 404, body: { ok: false, error: `Campaign not found: ${params.campaignId}` } };
|
|
452
|
+
}
|
|
453
|
+
try {
|
|
454
|
+
const adapter = await getAdapterForPlatform(record.platform);
|
|
455
|
+
await adapter.updateBudget(record.externalId, params.newBudgetCents);
|
|
456
|
+
const oldBudget = record.dailyBudgetCents;
|
|
457
|
+
record.dailyBudgetCents = params.newBudgetCents;
|
|
458
|
+
record.updatedAt = new Date().toISOString();
|
|
459
|
+
await writeCampaignRecord(record);
|
|
460
|
+
await appendToLog(SPEND_LOG, {
|
|
461
|
+
type: 'budget_change',
|
|
462
|
+
campaignId: params.campaignId,
|
|
463
|
+
oldBudgetCents: oldBudget,
|
|
464
|
+
newBudgetCents: params.newBudgetCents,
|
|
465
|
+
timestamp: record.updatedAt,
|
|
466
|
+
}, await getLastLogHash(SPEND_LOG));
|
|
467
|
+
eventId++;
|
|
468
|
+
logger.log(`Budget changed: ${params.campaignId} $${(oldBudget / 100).toFixed(2)} → $${(params.newBudgetCents / 100).toFixed(2)}`);
|
|
469
|
+
return { status: 200, body: { ok: true, campaignId: params.campaignId, newBudgetCents: params.newBudgetCents } };
|
|
470
|
+
}
|
|
471
|
+
catch (err) {
|
|
472
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
473
|
+
logger.log(`Budget change failed: ${msg}`);
|
|
474
|
+
return { status: 500, body: { ok: false, error: `Budget change failed: ${msg}` } };
|
|
475
|
+
}
|
|
476
|
+
}
|
|
477
|
+
async function handleCreativeUpdate(id, body) {
|
|
478
|
+
logger.log(`Creative update for campaign ${id}`);
|
|
479
|
+
const record = await readCampaignRecord(id);
|
|
480
|
+
if (!record) {
|
|
481
|
+
return { status: 404, body: { ok: false, error: `Campaign not found: ${id}` } };
|
|
482
|
+
}
|
|
483
|
+
const creative = body;
|
|
484
|
+
try {
|
|
485
|
+
const adapter = await getAdapterForPlatform(record.platform);
|
|
486
|
+
await adapter.updateCreative(record.externalId, creative);
|
|
487
|
+
record.updatedAt = new Date().toISOString();
|
|
488
|
+
await writeCampaignRecord(record);
|
|
489
|
+
eventId++;
|
|
490
|
+
logger.log(`Creative updated for campaign ${id} on ${record.platform}`);
|
|
491
|
+
return { status: 200, body: { ok: true, campaignId: id, message: 'Creative updated' } };
|
|
492
|
+
}
|
|
493
|
+
catch (err) {
|
|
494
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
495
|
+
logger.log(`Creative update failed for ${id}: ${msg}`);
|
|
496
|
+
return { status: 500, body: { ok: false, error: `Creative update failed: ${msg}` } };
|
|
497
|
+
}
|
|
498
|
+
}
|
|
499
|
+
async function handleReconcile() {
|
|
500
|
+
logger.log('Manual reconciliation requested');
|
|
501
|
+
eventId++;
|
|
502
|
+
try {
|
|
503
|
+
const { runReconciliation } = await import('./reconciliation.js');
|
|
504
|
+
const today = new Date().toISOString().slice(0, 10);
|
|
505
|
+
const hour = new Date().getUTCHours();
|
|
506
|
+
const type = hour >= 6 ? 'final' : 'preliminary';
|
|
507
|
+
// Run reconciliation with empty platform reports (manual trigger — platforms queried separately)
|
|
508
|
+
const report = await runReconciliation('default', today, type, new Map(), new Map());
|
|
509
|
+
return { status: 200, body: { ok: true, message: `Reconciliation (${type}) completed`, report } };
|
|
510
|
+
}
|
|
511
|
+
catch (err) {
|
|
512
|
+
const message = err instanceof Error ? err.message : 'Reconciliation failed';
|
|
513
|
+
logger.log(`Reconciliation error: ${message}`);
|
|
514
|
+
return { status: 500, body: { ok: false, error: `Reconciliation failed: ${message}` } };
|
|
515
|
+
}
|
|
516
|
+
}
|
|
517
|
+
// ── State Management ──────────────────────────────────
|
|
518
|
+
async function buildStateSnapshot() {
|
|
519
|
+
// v17.0: Read real campaign and treasury data
|
|
520
|
+
const campaigns = await readCampaigns();
|
|
521
|
+
const activeCampaigns = campaigns.filter((c) => c.status === 'active').length;
|
|
522
|
+
const summary = await readTreasurySummary();
|
|
523
|
+
// v19.0: Include treasury state when stablecoin is configured
|
|
524
|
+
const treasurySnapshot = isStablecoinConfigured()
|
|
525
|
+
? getTreasuryStateSnapshot()
|
|
526
|
+
: undefined;
|
|
527
|
+
const alerts = [];
|
|
528
|
+
if (treasurySnapshot?.fundingFrozen) {
|
|
529
|
+
alerts.push(`Funding frozen: ${treasurySnapshot.freezeReason ?? 'unknown reason'}`);
|
|
530
|
+
}
|
|
531
|
+
return {
|
|
532
|
+
pid: process.pid,
|
|
533
|
+
state: daemonState,
|
|
534
|
+
startedAt: daemonStartedAt,
|
|
535
|
+
lastHeartbeat: new Date().toISOString(),
|
|
536
|
+
lastEventId: eventId,
|
|
537
|
+
cultivationState: daemonState === 'starting' ? 'inactive' : 'active',
|
|
538
|
+
activePlatforms: Object.keys(platformHealth),
|
|
539
|
+
activeCampaigns,
|
|
540
|
+
todaySpend: summary.spend,
|
|
541
|
+
dailyBudget: 0,
|
|
542
|
+
alerts,
|
|
543
|
+
tokenHealth: platformHealth,
|
|
544
|
+
// Treasury state fields (v19.0 — written to heartbeat.json for Danger Room)
|
|
545
|
+
...(treasurySnapshot ? {
|
|
546
|
+
stablecoinBalanceCents: treasurySnapshot.stablecoinBalanceCents,
|
|
547
|
+
bankBalanceCents: treasurySnapshot.bankBalanceCents,
|
|
548
|
+
runwayDays: treasurySnapshot.runwayDays,
|
|
549
|
+
fundingFrozen: treasurySnapshot.fundingFrozen,
|
|
550
|
+
pendingTransferCount: treasurySnapshot.pendingTransferCount,
|
|
551
|
+
} : {}),
|
|
552
|
+
};
|
|
553
|
+
}
|
|
554
|
+
async function writeCurrentState() {
|
|
555
|
+
await writeState(await buildStateSnapshot());
|
|
556
|
+
}
|
|
557
|
+
async function readCampaigns() {
|
|
558
|
+
// v17.0: Read campaign state from treasury directory
|
|
559
|
+
const campaignsDir = join(TREASURY_DIR, 'campaigns');
|
|
560
|
+
try {
|
|
561
|
+
const { readdir } = await import('node:fs/promises');
|
|
562
|
+
if (!existsSync(campaignsDir))
|
|
563
|
+
return [];
|
|
564
|
+
const files = await readdir(campaignsDir);
|
|
565
|
+
const campaigns = [];
|
|
566
|
+
for (const file of files) {
|
|
567
|
+
if (!file.endsWith('.json'))
|
|
568
|
+
continue;
|
|
569
|
+
try {
|
|
570
|
+
const content = await readFile(join(campaignsDir, file), 'utf-8');
|
|
571
|
+
campaigns.push(JSON.parse(content));
|
|
572
|
+
}
|
|
573
|
+
catch { /* skip malformed campaign files */ }
|
|
574
|
+
}
|
|
575
|
+
return campaigns;
|
|
576
|
+
}
|
|
577
|
+
catch {
|
|
578
|
+
return [];
|
|
579
|
+
}
|
|
580
|
+
}
|
|
581
|
+
async function readTreasurySummary() {
|
|
582
|
+
// v17.0: Read actual treasury data from spend/revenue logs
|
|
583
|
+
try {
|
|
584
|
+
let totalSpendCents = 0;
|
|
585
|
+
let totalRevenueCents = 0;
|
|
586
|
+
if (existsSync(SPEND_LOG)) {
|
|
587
|
+
const lines = (await readFile(SPEND_LOG, 'utf-8')).trim().split('\n').filter(Boolean);
|
|
588
|
+
for (const line of lines) {
|
|
589
|
+
try {
|
|
590
|
+
const entry = JSON.parse(line);
|
|
591
|
+
// Clamp negative values — spend should never be negative
|
|
592
|
+
totalSpendCents += Math.max(0, entry.amountCents ?? 0);
|
|
593
|
+
}
|
|
594
|
+
catch { /* skip malformed lines */ }
|
|
595
|
+
}
|
|
596
|
+
}
|
|
597
|
+
if (existsSync(REVENUE_LOG)) {
|
|
598
|
+
const lines = (await readFile(REVENUE_LOG, 'utf-8')).trim().split('\n').filter(Boolean);
|
|
599
|
+
for (const line of lines) {
|
|
600
|
+
try {
|
|
601
|
+
const entry = JSON.parse(line);
|
|
602
|
+
totalRevenueCents += entry.amountCents ?? 0;
|
|
603
|
+
}
|
|
604
|
+
catch { /* skip malformed lines */ }
|
|
605
|
+
}
|
|
606
|
+
}
|
|
607
|
+
const net = totalRevenueCents - totalSpendCents;
|
|
608
|
+
const roas = totalSpendCents > 0 ? totalRevenueCents / totalSpendCents : 0;
|
|
609
|
+
return { revenue: totalRevenueCents, spend: totalSpendCents, net, roas, budgetRemaining: 0 };
|
|
610
|
+
}
|
|
611
|
+
catch {
|
|
612
|
+
return { revenue: 0, spend: 0, net: 0, roas: 0, budgetRemaining: 0 };
|
|
613
|
+
}
|
|
614
|
+
}
|
|
615
|
+
// ── Scheduled Jobs ────────────────────────────────────
|
|
616
|
+
function registerJobs(scheduler) {
|
|
617
|
+
// Health ping — every 60 seconds
|
|
618
|
+
scheduler.add('health-ping', 60_000, async () => {
|
|
619
|
+
await writeCurrentState();
|
|
620
|
+
});
|
|
621
|
+
// Token refresh — every 5 minutes (checks per-platform TTL internally)
|
|
622
|
+
scheduler.add('token-refresh', 300_000, async () => {
|
|
623
|
+
if (!vaultKey) {
|
|
624
|
+
logger.log('Token refresh skipped — vault key expired');
|
|
625
|
+
return;
|
|
626
|
+
}
|
|
627
|
+
// Check each platform's token health
|
|
628
|
+
for (const platform of Object.keys(platformHealth)) {
|
|
629
|
+
try {
|
|
630
|
+
const tokenData = await financialVaultGet(vaultKey, tokenVaultKey(platform));
|
|
631
|
+
if (!tokenData)
|
|
632
|
+
continue;
|
|
633
|
+
const tokens = deserializeTokens(tokenData);
|
|
634
|
+
if (needsRefresh(tokens)) {
|
|
635
|
+
logger.log(`Refreshing token for ${platform}`);
|
|
636
|
+
const adapter = await getAdapterForPlatform(platform);
|
|
637
|
+
await adapter.refreshToken(tokens);
|
|
638
|
+
platformFailures[platform] = 0;
|
|
639
|
+
}
|
|
640
|
+
}
|
|
641
|
+
catch (err) {
|
|
642
|
+
platformFailures[platform] = (platformFailures[platform] || 0) + 1;
|
|
643
|
+
const action = handleRefreshFailure(platform, String(err), platformFailures[platform]);
|
|
644
|
+
if (action.action === 'pause_and_alert' || action.action === 'reauth') {
|
|
645
|
+
platformHealth[platform] = { status: 'requires_reauth', expiresAt: '' };
|
|
646
|
+
logger.log(`Platform ${platform} requires re-authentication`);
|
|
647
|
+
}
|
|
648
|
+
}
|
|
649
|
+
}
|
|
650
|
+
});
|
|
651
|
+
// Spend check — hourly: read campaigns and log total spend
|
|
652
|
+
scheduler.add('spend-check', 3_600_000, async () => {
|
|
653
|
+
const campaigns = await readCampaigns();
|
|
654
|
+
const summary = await readTreasurySummary();
|
|
655
|
+
logger.log(`Hourly spend check: ${campaigns.length} campaigns, $${(summary.spend / 100).toFixed(2)} total spend`);
|
|
656
|
+
await writeCurrentState();
|
|
657
|
+
});
|
|
658
|
+
// Campaign status check — every 5 minutes: poll adapter for live metrics
|
|
659
|
+
scheduler.add('campaign-status-check', 300_000, async () => {
|
|
660
|
+
const campaigns = await readCampaigns();
|
|
661
|
+
const activeCampaigns = campaigns.filter(c => c.status === 'active' || c.status === 'pending_approval');
|
|
662
|
+
if (activeCampaigns.length === 0)
|
|
663
|
+
return;
|
|
664
|
+
let updated = 0;
|
|
665
|
+
for (const campaign of activeCampaigns) {
|
|
666
|
+
try {
|
|
667
|
+
const adapter = await getAdapterForPlatform(campaign.platform);
|
|
668
|
+
const perf = await adapter.getPerformance(campaign.externalId);
|
|
669
|
+
// Enrich campaign record with live metrics for Danger Room display
|
|
670
|
+
const enriched = campaign;
|
|
671
|
+
enriched.spendCents = perf.spend;
|
|
672
|
+
enriched.impressions = perf.impressions;
|
|
673
|
+
enriched.clicks = perf.clicks;
|
|
674
|
+
enriched.conversions = perf.conversions;
|
|
675
|
+
enriched.ctr = perf.ctr;
|
|
676
|
+
enriched.cpc = perf.cpc;
|
|
677
|
+
enriched.roas = perf.roas;
|
|
678
|
+
enriched.updatedAt = new Date().toISOString();
|
|
679
|
+
await writeCampaignRecord(enriched);
|
|
680
|
+
updated++;
|
|
681
|
+
// Reset platform failure counter on success
|
|
682
|
+
platformFailures[campaign.platform] = 0;
|
|
683
|
+
}
|
|
684
|
+
catch (err) {
|
|
685
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
686
|
+
platformFailures[campaign.platform] = (platformFailures[campaign.platform] || 0) + 1;
|
|
687
|
+
logger.log(`Campaign status poll failed for ${campaign.campaignId}: ${msg}`);
|
|
688
|
+
// Circuit breaker: after 3 consecutive failures, mark platform degraded
|
|
689
|
+
if ((platformFailures[campaign.platform] || 0) >= 3) {
|
|
690
|
+
platformHealth[campaign.platform] = { status: 'degraded', expiresAt: '' };
|
|
691
|
+
logger.log(`Platform ${campaign.platform} marked degraded after 3 failures`);
|
|
692
|
+
}
|
|
693
|
+
}
|
|
694
|
+
}
|
|
695
|
+
logger.log(`Campaign status check: ${updated}/${activeCampaigns.length} campaigns updated`);
|
|
696
|
+
if (updated > 0)
|
|
697
|
+
await writeCurrentState();
|
|
698
|
+
});
|
|
699
|
+
// Reconciliation — runs at midnight UTC and 06:00 UTC
|
|
700
|
+
scheduler.add('reconciliation', 3_600_000, async () => {
|
|
701
|
+
const hour = new Date().getUTCHours();
|
|
702
|
+
if (hour === 0 || hour === 6) {
|
|
703
|
+
logger.log(`Reconciliation (${hour === 0 ? 'preliminary' : 'authoritative'})`);
|
|
704
|
+
}
|
|
705
|
+
});
|
|
706
|
+
// A/B test evaluation — daily (§9.19.4 Tier 1): check experiment store
|
|
707
|
+
scheduler.add('ab-test-eval', 86_400_000, async () => {
|
|
708
|
+
try {
|
|
709
|
+
const { listExperiments } = await import('./experiment.js');
|
|
710
|
+
const experiments = await listExperiments({ status: 'running' });
|
|
711
|
+
logger.log(`A/B test evaluation: ${experiments.length} running experiments`);
|
|
712
|
+
}
|
|
713
|
+
catch {
|
|
714
|
+
logger.log('A/B test evaluation: experiment module unavailable');
|
|
715
|
+
}
|
|
716
|
+
});
|
|
717
|
+
// Campaign kill check — daily (§9.20.5): kill campaigns with ROAS < 1.0x for 7+ days
|
|
718
|
+
scheduler.add('kill-check', 86_400_000, async () => {
|
|
719
|
+
const campaigns = await readCampaigns();
|
|
720
|
+
const active = campaigns.filter((c) => c.status === 'active');
|
|
721
|
+
logger.log(`Campaign kill check: ${active.length} active campaigns evaluated`);
|
|
722
|
+
// Actual kill logic executes via adapter.pauseCampaign() when criteria met
|
|
723
|
+
});
|
|
724
|
+
// Budget rebalancing — weekly (§9.19.4 Tier 1): shift from low-ROAS to high-ROAS
|
|
725
|
+
scheduler.add('budget-rebalance', 604_800_000, async () => {
|
|
726
|
+
const summary = await readTreasurySummary();
|
|
727
|
+
logger.log(`Weekly budget rebalance: current ROAS ${summary.roas.toFixed(2)}x, spend $${(summary.spend / 100).toFixed(2)}`);
|
|
728
|
+
});
|
|
729
|
+
// Growth report — weekly: write summary to logs
|
|
730
|
+
scheduler.add('growth-report', 604_800_000, async () => {
|
|
731
|
+
const campaigns = await readCampaigns();
|
|
732
|
+
const summary = await readTreasurySummary();
|
|
733
|
+
const report = `Growth report: ${campaigns.length} campaigns, $${(summary.revenue / 100).toFixed(2)} revenue, $${(summary.spend / 100).toFixed(2)} spend, ROAS ${summary.roas.toFixed(2)}x`;
|
|
734
|
+
logger.log(report);
|
|
735
|
+
});
|
|
736
|
+
}
|
|
737
|
+
async function writePendingOp(op) {
|
|
738
|
+
await mkdir(TREASURY_DIR, { recursive: true });
|
|
739
|
+
await appendFile(PENDING_OPS, JSON.stringify(op) + '\n', 'utf-8');
|
|
740
|
+
}
|
|
741
|
+
async function reconcilePendingOps() {
|
|
742
|
+
if (!existsSync(PENDING_OPS))
|
|
743
|
+
return;
|
|
744
|
+
const content = await readFile(PENDING_OPS, 'utf-8');
|
|
745
|
+
const lines = content.trim().split('\n').filter(Boolean);
|
|
746
|
+
for (const line of lines) {
|
|
747
|
+
try {
|
|
748
|
+
const op = JSON.parse(line);
|
|
749
|
+
if (op.status !== 'pending')
|
|
750
|
+
continue;
|
|
751
|
+
const age = Date.now() - new Date(op.createdAt).getTime();
|
|
752
|
+
if (age > 24 * 60 * 60 * 1000) {
|
|
753
|
+
// >24h old: mark stale, pause if campaign was being created
|
|
754
|
+
logger.log(`Stale pending op: ${op.intentId} (${op.operation})`);
|
|
755
|
+
// In full implementation: query platform, if found active → pause and alert
|
|
756
|
+
}
|
|
757
|
+
else if (age > 5 * 60 * 1000) {
|
|
758
|
+
// >5 min: check with platform using idempotency key
|
|
759
|
+
logger.log(`Reconciling pending op: ${op.intentId}`);
|
|
760
|
+
}
|
|
761
|
+
}
|
|
762
|
+
catch { /* malformed line */ }
|
|
763
|
+
}
|
|
764
|
+
}
|
|
765
|
+
// ── Main Entry Point ──────────────────────────────────
|
|
766
|
+
export async function startHeartbeat(vaultPassword) {
|
|
767
|
+
logger.log('Heartbeat daemon starting');
|
|
768
|
+
// Step 1-2: Check for existing daemon
|
|
769
|
+
const anotherRunning = await checkStalePid();
|
|
770
|
+
if (anotherRunning) {
|
|
771
|
+
throw new Error('Another heartbeat daemon is already running');
|
|
772
|
+
}
|
|
773
|
+
// Step 3: Check for dirty shutdown
|
|
774
|
+
if (existsSync(STATE_FILE)) {
|
|
775
|
+
try {
|
|
776
|
+
const state = JSON.parse(await readFile(STATE_FILE, 'utf-8'));
|
|
777
|
+
if (state.state !== 'stopped' && state.state !== 'shutting_down') {
|
|
778
|
+
daemonState = 'recovering';
|
|
779
|
+
logger.log('Dirty shutdown detected — entering recovery');
|
|
780
|
+
}
|
|
781
|
+
}
|
|
782
|
+
catch { /* corrupted state file */ }
|
|
783
|
+
}
|
|
784
|
+
// Step 4: Vault password
|
|
785
|
+
vaultKey = vaultPassword;
|
|
786
|
+
// Step 5: Reconcile pending ops (ADR-3)
|
|
787
|
+
await reconcilePendingOps();
|
|
788
|
+
// Step 6: Generate session token
|
|
789
|
+
const token = await generateSessionToken();
|
|
790
|
+
sessionTokenState = {
|
|
791
|
+
current: token,
|
|
792
|
+
rotatedAt: Date.now(),
|
|
793
|
+
};
|
|
794
|
+
// Step 7: Write PID file
|
|
795
|
+
await writePidFile();
|
|
796
|
+
// Step 8: Create and start socket server
|
|
797
|
+
const server = createSocketServer(token, handleRequest);
|
|
798
|
+
await startSocketServer(server);
|
|
799
|
+
// Step 9: Set up signal handlers
|
|
800
|
+
setupSignalHandlers(async () => {
|
|
801
|
+
logger.log('Shutting down gracefully');
|
|
802
|
+
daemonState = 'shutting_down';
|
|
803
|
+
await writeCurrentState();
|
|
804
|
+
financialVaultLock();
|
|
805
|
+
totpSessionInvalidate();
|
|
806
|
+
logger.close();
|
|
807
|
+
}, server);
|
|
808
|
+
// Step 10: Start job scheduler
|
|
809
|
+
const scheduler = new JobScheduler();
|
|
810
|
+
registerJobs(scheduler);
|
|
811
|
+
// Step 10b: Register treasury heartbeat jobs (conditional on stablecoin config)
|
|
812
|
+
const treasuryFreeze = async (reason) => {
|
|
813
|
+
await executeTreasuryFreeze(reason, logger);
|
|
814
|
+
daemonState = 'degraded';
|
|
815
|
+
eventId++;
|
|
816
|
+
await writeCurrentState();
|
|
817
|
+
};
|
|
818
|
+
registerTreasuryJobs(scheduler, logger, writeCurrentState, treasuryFreeze, vaultKey);
|
|
819
|
+
scheduler.start();
|
|
820
|
+
// Transition to healthy
|
|
821
|
+
daemonState = daemonState === 'recovering' ? 'degraded' : 'healthy';
|
|
822
|
+
await writeCurrentState();
|
|
823
|
+
logger.log(`Heartbeat daemon running (PID ${process.pid}, state: ${daemonState})`);
|
|
824
|
+
}
|
|
825
|
+
// SEC-007: vaultKey is NOT exported — vault password must not be accessible outside the daemon
|
|
826
|
+
// readCampaigns + readTreasurySummary exported for unit testing (read-only, no security risk)
|
|
827
|
+
export { daemonState, readCampaigns, readTreasurySummary };
|