dexterm-cli 0.0.0-stage → 0.1.1
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/LICENSE +661 -0
- package/dist/packages/agents/src/audit.js +122 -0
- package/dist/packages/agents/src/bus.js +379 -0
- package/dist/packages/agents/src/executor.js +177 -0
- package/dist/packages/agents/src/gate.js +176 -0
- package/dist/packages/agents/src/identity.js +179 -0
- package/dist/packages/agents/src/index.js +15 -0
- package/dist/packages/agents/src/proposals.js +153 -0
- package/dist/packages/agents/src/schema.js +87 -0
- package/dist/packages/agents/src/sdk.js +140 -0
- package/dist/packages/agents/src/tiers.js +114 -0
- package/dist/packages/agents/src/tools.js +69 -0
- package/dist/packages/agents/src/ws.js +158 -0
- package/dist/packages/cli/assets/claude/analyst-system.md +76 -0
- package/dist/packages/cli/assets/claude/plugin/.claude-plugin/plugin.json +5 -0
- package/dist/packages/cli/assets/claude/plugin/skills/REGISTRY.json +16 -0
- package/dist/packages/cli/assets/claude/plugin/skills/larp-check/SKILL.md +67 -0
- package/dist/packages/cli/assets/claude/plugin/skills/rug-check/SKILL.md +64 -0
- package/dist/packages/cli/assets/claude/plugin/skills/token-dossier/SKILL.md +63 -0
- package/dist/packages/cli/assets/claude/plugin/skills/trench-scan/SKILL.md +44 -0
- package/dist/packages/cli/assets/claude/schemas/larp.schema.json +117 -0
- package/dist/packages/cli/assets/claude/schemas/trench-scan.schema.json +71 -0
- package/dist/packages/cli/assets/claude/schemas/verdict.schema.json +115 -0
- package/dist/packages/cli/bin/dexterm.mjs +11 -0
- package/dist/packages/cli/src/agent-cmd.js +303 -0
- package/dist/packages/cli/src/agent-roster.js +26 -0
- package/dist/packages/cli/src/analyst/backend.js +526 -0
- package/dist/packages/cli/src/analyst/results.js +38 -0
- package/dist/packages/cli/src/analyst/session.js +319 -0
- package/dist/packages/cli/src/audit.js +51 -0
- package/dist/packages/cli/src/backtest-cmd.js +149 -0
- package/dist/packages/cli/src/boot-state.js +62 -0
- package/dist/packages/cli/src/custom.js +261 -0
- package/dist/packages/cli/src/display.js +191 -0
- package/dist/packages/cli/src/doctor.js +361 -0
- package/dist/packages/cli/src/feed.js +634 -0
- package/dist/packages/cli/src/fixtures.js +275 -0
- package/dist/packages/cli/src/follow-cmd.js +221 -0
- package/dist/packages/cli/src/images-config.js +59 -0
- package/dist/packages/cli/src/index.js +31 -0
- package/dist/packages/cli/src/kb-skill-cmd.js +240 -0
- package/dist/packages/cli/src/larp-record.js +21 -0
- package/dist/packages/cli/src/live/copy.js +80 -0
- package/dist/packages/cli/src/live/flow.js +233 -0
- package/dist/packages/cli/src/live/index.js +509 -0
- package/dist/packages/cli/src/live/intel.js +227 -0
- package/dist/packages/cli/src/live/newpairs.js +334 -0
- package/dist/packages/cli/src/live/screens/newpairs-live.js +219 -0
- package/dist/packages/cli/src/live/screens/token-live.js +459 -0
- package/dist/packages/cli/src/live/screens/watchlist-live.js +149 -0
- package/dist/packages/cli/src/live/token.js +585 -0
- package/dist/packages/cli/src/live/trade.js +929 -0
- package/dist/packages/cli/src/live/watchlist.js +178 -0
- package/dist/packages/cli/src/main.js +5583 -0
- package/dist/packages/cli/src/narrative-cmd.js +76 -0
- package/dist/packages/cli/src/pack-cmd.js +323 -0
- package/dist/packages/cli/src/plan-cmd.js +278 -0
- package/dist/packages/cli/src/popup.js +810 -0
- package/dist/packages/cli/src/rules-cmd.js +131 -0
- package/dist/packages/cli/src/rules.js +339 -0
- package/dist/packages/cli/src/scan/command.js +57 -0
- package/dist/packages/cli/src/scan/engine.js +419 -0
- package/dist/packages/cli/src/scan/observe.js +64 -0
- package/dist/packages/cli/src/scan/ui.js +98 -0
- package/dist/packages/cli/src/screener/filter.js +241 -0
- package/dist/packages/cli/src/screener/presets.js +80 -0
- package/dist/packages/cli/src/screener/rows.js +146 -0
- package/dist/packages/cli/src/screener/rug.js +50 -0
- package/dist/packages/cli/src/screener/sort.js +52 -0
- package/dist/packages/cli/src/screener/view.js +183 -0
- package/dist/packages/cli/src/screens/analyst-session.js +343 -0
- package/dist/packages/cli/src/screens/analyst.js +326 -0
- package/dist/packages/cli/src/screens/boot.js +74 -0
- package/dist/packages/cli/src/screens/bubbles.js +255 -0
- package/dist/packages/cli/src/screens/coin.js +33 -0
- package/dist/packages/cli/src/screens/follows.js +201 -0
- package/dist/packages/cli/src/screens/kol.js +90 -0
- package/dist/packages/cli/src/screens/narrative.js +90 -0
- package/dist/packages/cli/src/screens/newpairs.js +150 -0
- package/dist/packages/cli/src/screens/overlay.js +257 -0
- package/dist/packages/cli/src/screens/plans.js +126 -0
- package/dist/packages/cli/src/screens/positions.js +176 -0
- package/dist/packages/cli/src/screens/rules.js +170 -0
- package/dist/packages/cli/src/screens/screener.js +459 -0
- package/dist/packages/cli/src/screens/social-panel.js +107 -0
- package/dist/packages/cli/src/screens/ticket.js +342 -0
- package/dist/packages/cli/src/screens/token.js +312 -0
- package/dist/packages/cli/src/screens/trending.js +181 -0
- package/dist/packages/cli/src/screens/watchlist.js +104 -0
- package/dist/packages/cli/src/social/demo.js +96 -0
- package/dist/packages/cli/src/social/view.js +250 -0
- package/dist/packages/cli/src/state.js +463 -0
- package/dist/packages/cli/src/theme-cmd.js +126 -0
- package/dist/packages/cli/src/trade.js +319 -0
- package/dist/packages/cli/src/voice-hud.js +42 -0
- package/dist/packages/cli/src/wallet-cmd.js +440 -0
- package/dist/packages/cli/src/wallet-command.js +31 -0
- package/dist/packages/cli/src/widgets.js +208 -0
- package/dist/packages/cli/src/workspace-cmd.js +68 -0
- package/dist/packages/cli/src/x-cmd.js +154 -0
- package/dist/packages/custom/src/config.js +213 -0
- package/dist/packages/custom/src/index.js +10 -0
- package/dist/packages/custom/src/keymap.js +107 -0
- package/dist/packages/custom/src/layout.js +235 -0
- package/dist/packages/custom/src/packs.js +633 -0
- package/dist/packages/custom/src/rules.js +704 -0
- package/dist/packages/custom/src/schema.js +515 -0
- package/dist/packages/custom/src/themes.js +176 -0
- package/dist/packages/custom/src/widget-html.js +222 -0
- package/dist/packages/custom/src/workspace.js +141 -0
- package/dist/packages/data/src/analyst-actions.js +133 -0
- package/dist/packages/data/src/budget.js +88 -0
- package/dist/packages/data/src/cache.js +142 -0
- package/dist/packages/data/src/candles.js +168 -0
- package/dist/packages/data/src/clock.js +64 -0
- package/dist/packages/data/src/credits.js +137 -0
- package/dist/packages/data/src/detail.js +97 -0
- package/dist/packages/data/src/fast-lane.js +321 -0
- package/dist/packages/data/src/fixtures.js +135 -0
- package/dist/packages/data/src/health.js +245 -0
- package/dist/packages/data/src/http.js +277 -0
- package/dist/packages/data/src/images/cache.js +141 -0
- package/dist/packages/data/src/images/decode-worker.js +28 -0
- package/dist/packages/data/src/images/decode.js +81 -0
- package/dist/packages/data/src/images/fetch.js +162 -0
- package/dist/packages/data/src/images/gif.js +214 -0
- package/dist/packages/data/src/images/index.js +10 -0
- package/dist/packages/data/src/images/jpeg.js +544 -0
- package/dist/packages/data/src/images/png.js +310 -0
- package/dist/packages/data/src/images/service.js +334 -0
- package/dist/packages/data/src/images/types.js +27 -0
- package/dist/packages/data/src/index.js +208 -0
- package/dist/packages/data/src/intel/helius-history.js +59 -0
- package/dist/packages/data/src/intel/holder-class.js +125 -0
- package/dist/packages/data/src/intel/rules.js +421 -0
- package/dist/packages/data/src/intel/scan.js +170 -0
- package/dist/packages/data/src/larp/evidence.js +288 -0
- package/dist/packages/data/src/larp/links.js +137 -0
- package/dist/packages/data/src/larp/robots.js +90 -0
- package/dist/packages/data/src/larp/safe-fetch.js +389 -0
- package/dist/packages/data/src/limiter.js +341 -0
- package/dist/packages/data/src/model.js +12 -0
- package/dist/packages/data/src/paper.js +331 -0
- package/dist/packages/data/src/rpc-config.js +126 -0
- package/dist/packages/data/src/rpc-pool.js +166 -0
- package/dist/packages/data/src/social/chain.js +83 -0
- package/dist/packages/data/src/social/dedupe.js +205 -0
- package/dist/packages/data/src/social/feed.js +462 -0
- package/dist/packages/data/src/social/firewall.js +110 -0
- package/dist/packages/data/src/social/index.js +11 -0
- package/dist/packages/data/src/social/kol.js +269 -0
- package/dist/packages/data/src/social/model.js +11 -0
- package/dist/packages/data/src/social/rank.js +184 -0
- package/dist/packages/data/src/social/rss.js +113 -0
- package/dist/packages/data/src/social/summary.js +104 -0
- package/dist/packages/data/src/social/venues.js +186 -0
- package/dist/packages/data/src/sources/dexscreener.js +170 -0
- package/dist/packages/data/src/sources/geckoterminal.js +133 -0
- package/dist/packages/data/src/sources/goplus.js +161 -0
- package/dist/packages/data/src/sources/jupiter-swap.js +236 -0
- package/dist/packages/data/src/sources/jupiter.js +198 -0
- package/dist/packages/data/src/sources/pda.js +113 -0
- package/dist/packages/data/src/sources/pump-curve.js +58 -0
- package/dist/packages/data/src/sources/pumpportal.js +247 -0
- package/dist/packages/data/src/sources/rugcheck.js +71 -0
- package/dist/packages/data/src/sources/solana-rpc.js +299 -0
- package/dist/packages/data/src/sources/solana-ws.js +287 -0
- package/dist/packages/data/src/store.js +289 -0
- package/dist/packages/data/src/trade/bridge.js +464 -0
- package/dist/packages/data/src/trade/caps.js +323 -0
- package/dist/packages/data/src/trade/copy/auto.js +385 -0
- package/dist/packages/data/src/trade/copy/follows.js +103 -0
- package/dist/packages/data/src/trade/copy/index.js +9 -0
- package/dist/packages/data/src/trade/copy/mirror.js +91 -0
- package/dist/packages/data/src/trade/copy/stats.js +120 -0
- package/dist/packages/data/src/trade/copy/tracker.js +158 -0
- package/dist/packages/data/src/trade/copy/trades.js +87 -0
- package/dist/packages/data/src/trade/copy/types.js +6 -0
- package/dist/packages/data/src/trade/executor.js +933 -0
- package/dist/packages/data/src/trade/kill-switch.js +47 -0
- package/dist/packages/data/src/trade/link-store.js +52 -0
- package/dist/packages/data/src/trade/plans/confirm.js +157 -0
- package/dist/packages/data/src/trade/plans/engine.js +784 -0
- package/dist/packages/data/src/trade/plans/executor-live.js +220 -0
- package/dist/packages/data/src/trade/plans/executor-paper.js +44 -0
- package/dist/packages/data/src/trade/plans/executor.js +11 -0
- package/dist/packages/data/src/trade/plans/index.js +12 -0
- package/dist/packages/data/src/trade/plans/journal.js +77 -0
- package/dist/packages/data/src/trade/plans/mandate.js +129 -0
- package/dist/packages/data/src/trade/plans/plan.js +179 -0
- package/dist/packages/data/src/trade/plans/service.js +168 -0
- package/dist/packages/data/src/trade/plans/types.js +6 -0
- package/dist/packages/data/src/trade/rpc-exec.js +229 -0
- package/dist/packages/data/src/trade/signer/client.js +197 -0
- package/dist/packages/data/src/trade/signer/fast-wallet.js +154 -0
- package/dist/packages/data/src/trade/signer/host.js +97 -0
- package/dist/packages/data/src/trade/signer/main.js +121 -0
- package/dist/packages/data/src/trade/signer/probe-builder.js +85 -0
- package/dist/packages/data/src/trade/signer/protocol.js +92 -0
- package/dist/packages/data/src/trade/signer/service.js +400 -0
- package/dist/packages/data/src/trade/signer/sol-usd-feed.js +98 -0
- package/dist/packages/data/src/trade/sol-usd.js +86 -0
- package/dist/packages/data/src/trade/swap-build.js +108 -0
- package/dist/packages/data/src/trade/token-delta.js +201 -0
- package/dist/packages/data/src/trade/tx-decode.js +632 -0
- package/dist/packages/data/src/util.js +24 -0
- package/dist/packages/data/src/watchlist.js +176 -0
- package/dist/packages/data/src/x/api.js +256 -0
- package/dist/packages/data/src/x/budget.js +66 -0
- package/dist/packages/data/src/x/config.js +49 -0
- package/dist/packages/data/src/x/index.js +10 -0
- package/dist/packages/data/src/x/mock-server.js +217 -0
- package/dist/packages/data/src/x/oauth.js +242 -0
- package/dist/packages/data/src/x/pkce.js +21 -0
- package/dist/packages/data/src/x/source.js +285 -0
- package/dist/packages/data/src/x/token-store.js +134 -0
- package/dist/packages/data/src/x/transport.js +71 -0
- package/dist/packages/data/vendor/solana-web3/LICENSE +20 -0
- package/dist/packages/data/vendor/solana-web3/NOTICE.md +13 -0
- package/dist/packages/data/vendor/solana-web3/index.iife.min.js +20 -0
- package/dist/packages/mcp/assets/cases.json +1194 -0
- package/dist/packages/mcp/assets/kb/entries/cabal-and-coordinated-launches.md +28 -0
- package/dist/packages/mcp/assets/kb/entries/common-rug-patterns.md +40 -0
- package/dist/packages/mcp/assets/kb/entries/dbc-mechanics.md +30 -0
- package/dist/packages/mcp/assets/kb/entries/dev-wallet-behaviour.md +33 -0
- package/dist/packages/mcp/assets/kb/entries/glossary.md +35 -0
- package/dist/packages/mcp/assets/kb/entries/holder-concentration.md +30 -0
- package/dist/packages/mcp/assets/kb/entries/jito-bundles.md +29 -0
- package/dist/packages/mcp/assets/kb/entries/launchlab-curve.md +31 -0
- package/dist/packages/mcp/assets/kb/entries/lp-burn-vs-lock.md +31 -0
- package/dist/packages/mcp/assets/kb/entries/metadata-and-copycats.md +29 -0
- package/dist/packages/mcp/assets/kb/entries/mint-freeze-authority.md +31 -0
- package/dist/packages/mcp/assets/kb/entries/narrative-lifecycle.md +32 -0
- package/dist/packages/mcp/assets/kb/entries/pump-bonding-curve.md +33 -0
- package/dist/packages/mcp/assets/kb/entries/pumpswap-migration.md +29 -0
- package/dist/packages/mcp/assets/kb/entries/sniping-and-snipers.md +29 -0
- package/dist/packages/mcp/assets/kb/entries/token2022-extensions-risk.md +32 -0
- package/dist/packages/mcp/assets/kb/entries/wash-trading-fake-volume.md +33 -0
- package/dist/packages/mcp/assets/skills/rug-check.md +52 -0
- package/dist/packages/mcp/assets/skills/token-dossier.md +57 -0
- package/dist/packages/mcp/assets/skills/trench-scan.md +40 -0
- package/dist/packages/mcp/bin/dexterm-mcp.mjs +16 -0
- package/dist/packages/mcp/src/dossier.js +78 -0
- package/dist/packages/mcp/src/firewall.js +21 -0
- package/dist/packages/mcp/src/index.js +22 -0
- package/dist/packages/mcp/src/server.js +849 -0
- package/dist/packages/mcp/src/user-content.js +434 -0
- package/dist/packages/packs/src/backtest.js +183 -0
- package/dist/packages/packs/src/digest.js +95 -0
- package/dist/packages/packs/src/firewall.js +57 -0
- package/dist/packages/packs/src/index.js +19 -0
- package/dist/packages/packs/src/larp-eval.js +100 -0
- package/dist/packages/packs/src/narrative.js +309 -0
- package/dist/packages/packs/src/reason.js +135 -0
- package/dist/packages/packs/src/rug-model.js +137 -0
- package/dist/packages/packs/src/schema.js +54 -0
- package/dist/packages/packs/src/token-dossier.js +174 -0
- package/dist/packages/packs/src/verdicts.js +17 -0
- package/dist/packages/packs/src/wallet-dossier.js +105 -0
- package/dist/packages/popup/src/config.js +71 -0
- package/dist/packages/popup/src/dock.js +271 -0
- package/dist/packages/popup/src/host.js +210 -0
- package/dist/packages/popup/src/index.js +12 -0
- package/dist/packages/popup/src/loopback.js +53 -0
- package/dist/packages/popup/src/open.js +183 -0
- package/dist/packages/popup/src/pages/bubbles-lib.js +160 -0
- package/dist/packages/popup/src/pages/bubbles.css +23 -0
- package/dist/packages/popup/src/pages/bubbles.html +36 -0
- package/dist/packages/popup/src/pages/bubbles.js +214 -0
- package/dist/packages/popup/src/pages/chart-lib.js +262 -0
- package/dist/packages/popup/src/pages/chart.css +50 -0
- package/dist/packages/popup/src/pages/chart.html +56 -0
- package/dist/packages/popup/src/pages/chart.js +554 -0
- package/dist/packages/popup/src/pages/common.css +34 -0
- package/dist/packages/popup/src/pages/common.js +187 -0
- package/dist/packages/popup/src/pages/feeds.html +20 -0
- package/dist/packages/popup/src/pages/feeds.js +77 -0
- package/dist/packages/popup/src/pages/narrative.html +20 -0
- package/dist/packages/popup/src/pages/narrative.js +61 -0
- package/dist/packages/popup/src/pages/plans.css +30 -0
- package/dist/packages/popup/src/pages/plans.html +26 -0
- package/dist/packages/popup/src/pages/plans.js +82 -0
- package/dist/packages/popup/src/pages/roster.css +41 -0
- package/dist/packages/popup/src/pages/roster.html +25 -0
- package/dist/packages/popup/src/pages/roster.js +75 -0
- package/dist/packages/popup/src/pages/token.html +20 -0
- package/dist/packages/popup/src/pages/token.js +112 -0
- package/dist/packages/popup/src/pages/widget-sdk.js +224 -0
- package/dist/packages/popup/src/pages/widget.css +5 -0
- package/dist/packages/popup/src/pages/widget.html +21 -0
- package/dist/packages/popup/src/pages/widget.js +85 -0
- package/dist/packages/popup/src/protocol.js +31 -0
- package/dist/packages/popup/src/selftest.js +156 -0
- package/dist/packages/popup/src/server.js +582 -0
- package/dist/packages/popup/src/sinkproxy.js +167 -0
- package/dist/packages/popup/src/widget-html.js +231 -0
- package/dist/packages/popup/vendor/lightweight-charts/LICENSE +201 -0
- package/dist/packages/popup/vendor/lightweight-charts/NOTICE +2 -0
- package/dist/packages/popup/vendor/lightweight-charts/README.md +30 -0
- package/dist/packages/popup/vendor/lightweight-charts/VERSION +1 -0
- package/dist/packages/popup/vendor/lightweight-charts/lightweight-charts.standalone.production.js +7 -0
- package/dist/packages/recorder/bin/dexterm-recorder.mjs +11 -0
- package/dist/packages/recorder/src/cli.js +113 -0
- package/dist/packages/recorder/src/export.js +71 -0
- package/dist/packages/recorder/src/index.js +20 -0
- package/dist/packages/recorder/src/labels.js +151 -0
- package/dist/packages/recorder/src/open.js +29 -0
- package/dist/packages/recorder/src/recorder.js +553 -0
- package/dist/packages/recorder/src/schedule.js +43 -0
- package/dist/packages/recorder/src/schema.js +135 -0
- package/dist/packages/recorder/src/snapshot.js +191 -0
- package/dist/packages/recorder/src/sweep.js +52 -0
- package/dist/packages/recorder/src/verdicts.js +52 -0
- package/dist/packages/tui/src/bubbles.js +415 -0
- package/dist/packages/tui/src/canvas.js +370 -0
- package/dist/packages/tui/src/chrome.js +85 -0
- package/dist/packages/tui/src/color.js +133 -0
- package/dist/packages/tui/src/flush.js +62 -0
- package/dist/packages/tui/src/fmt.js +188 -0
- package/dist/packages/tui/src/fx-trade.js +278 -0
- package/dist/packages/tui/src/fx.js +527 -0
- package/dist/packages/tui/src/images/blocks.js +141 -0
- package/dist/packages/tui/src/images/coin.js +22 -0
- package/dist/packages/tui/src/images/detect.js +140 -0
- package/dist/packages/tui/src/images/index.js +7 -0
- package/dist/packages/tui/src/images/layer.js +146 -0
- package/dist/packages/tui/src/images/pixels.js +157 -0
- package/dist/packages/tui/src/images/protocols.js +87 -0
- package/dist/packages/tui/src/index.js +15 -0
- package/dist/packages/tui/src/keymap.js +355 -0
- package/dist/packages/tui/src/layout.js +11 -0
- package/dist/packages/tui/src/sound.js +177 -0
- package/dist/packages/tui/src/theme.js +182 -0
- package/dist/packages/tui/src/widgets.js +598 -0
- package/dist/packages/voice/src/controller.js +395 -0
- package/dist/packages/voice/src/doctor.js +63 -0
- package/dist/packages/voice/src/engines.js +44 -0
- package/dist/packages/voice/src/index.js +16 -0
- package/dist/packages/voice/src/router.js +502 -0
- package/dist/packages/voice/src/stt/apple-helper.swift.js +103 -0
- package/dist/packages/voice/src/stt/apple.js +179 -0
- package/dist/packages/voice/src/stt/fake.js +34 -0
- package/dist/packages/voice/src/stt/whisper.js +102 -0
- package/dist/packages/voice/src/summary.js +44 -0
- package/dist/packages/voice/src/tts/fake.js +30 -0
- package/dist/packages/voice/src/tts/say.js +96 -0
- package/dist/packages/voice/src/types.js +3 -0
- package/package.json +42 -4
- package/README.md +0 -3
|
@@ -0,0 +1,582 @@
|
|
|
1
|
+
// dexterm popup host — loopback-only popup server (task-1290; TypeScript port of the spike server, which
|
|
2
|
+
// shipped 20/20 protocol tests). Zero runtime dependencies beyond node:http / node:crypto.
|
|
3
|
+
//
|
|
4
|
+
// Security posture (carried over verbatim from research/popup-host §5; see README "Security model"):
|
|
5
|
+
// * listens on 127.0.0.1 only, on a random port; never on 0.0.0.0 / ::.
|
|
6
|
+
// * every popup is opened with a single-use TICKET (32 random bytes, 30 s TTL) in the URL; the page the
|
|
7
|
+
// ticket buys embeds a per-popup WS TOKEN (meta tag) accepted exactly once for the WebSocket upgrade.
|
|
8
|
+
// The TUI hands the URL to the shell over stdin, never argv, so `ps` from another user cannot read it.
|
|
9
|
+
// * strict Host check on every request (DNS-rebind guard), strict Origin check on every WebSocket
|
|
10
|
+
// upgrade; remote address must be loopback. No CORS header is ever set. Unknown/unauthorised requests
|
|
11
|
+
// get an empty 404 and are reported to the TUI as `probe.rejected`.
|
|
12
|
+
// * all JS/CSS is inlined with a CSP nonce; CSP forbids remote content, inline handlers, eval, images, fonts.
|
|
13
|
+
// (task-1358 r2) a PACK WIDGET page gets no nonce at all: its inline blocks and our SDK are allow-listed by sha256,
|
|
14
|
+
// nothing is substituted into the pack html, and loaders are refused before a ticket is issued (widget-html.ts).
|
|
15
|
+
// (task-1358 r3) a widget is TWO documents: `/popup/widget` is OUR wrapper page (nonce CSP, owns the socket, and a
|
|
16
|
+
// `frame-src` that names exactly one route) holding an `<iframe sandbox="allow-scripts">`; `/popup/widget-frame`
|
|
17
|
+
// serves the pack html under the hash CSP with NO network (`connect-src 'none'`), an opaque origin and
|
|
18
|
+
// `frame-ancestors` = our origin. The parent's frame-src governs every navigation of the child (a document may
|
|
19
|
+
// always navigate ITSELF; `sandbox` only stops it navigating others), so a page that tries `location.href = 'https://…'`
|
|
20
|
+
// is refused by the engine — the browser-fallback exfil the r2 verifier found. Data reaches the frame by postMessage.
|
|
21
|
+
// * popup → TUI messages are schema-checked against an allow-list of UI event names; anything else is dropped.
|
|
22
|
+
// Popups can never send trade commands and never receive keys: the server only knows about view-models.
|
|
23
|
+
// (r3) a widget's socket accepts only the generic ready/perf/log/close/focus/drag — never a roster or plan event.
|
|
24
|
+
import http from 'node:http';
|
|
25
|
+
import { createHash, randomBytes, timingSafeEqual } from 'node:crypto';
|
|
26
|
+
import { readFileSync, existsSync } from 'node:fs';
|
|
27
|
+
import { fileURLToPath } from 'node:url';
|
|
28
|
+
import path from 'node:path';
|
|
29
|
+
import { PROTOCOL_VERSION, CHART_INTERVALS, CHART_INDICATORS, CHART_STYLES, CHART_SCALES, WIDGET_TOPICS, WIDGET_HTML_MAX } from "./protocol.js";
|
|
30
|
+
import { auditWidgetHtml, inlineHashes, normalizeWidgetHtml } from "./widget-html.js";
|
|
31
|
+
const here = path.dirname(fileURLToPath(import.meta.url));
|
|
32
|
+
const PAGES_DIR = path.join(here, 'pages');
|
|
33
|
+
/** task-1325: the vendored TradingView Lightweight Charts™ build (Apache-2.0), see vendor/lightweight-charts/README.md. */
|
|
34
|
+
export const LWC_DIR = path.join(here, '..', 'vendor', 'lightweight-charts');
|
|
35
|
+
export const LWC_FILE = 'lightweight-charts.standalone.production.js';
|
|
36
|
+
/** The ONE static route the server answers besides ticketed pages: exactly this path, GET, no query string. */
|
|
37
|
+
export const LWC_ROUTE = `/vendor/${LWC_FILE}`;
|
|
38
|
+
const TICKET_TTL_MS = 30_000;
|
|
39
|
+
/** task-1326: the one image route, `GET /img/<32 hex>.png`, no query string. */
|
|
40
|
+
export const IMAGE_ROUTE_RE = /^\/img\/([0-9a-f]{32})\.png$/;
|
|
41
|
+
export const IMAGE_ROUTE_PREFIX = '/img/';
|
|
42
|
+
const MAX_WS_MSG = 4096;
|
|
43
|
+
const LOOPBACK = new Set(['127.0.0.1', '::ffff:127.0.0.1', '::1']);
|
|
44
|
+
/** UI events a popup may send. Anything outside this list is dropped and reported as `popup.dropped`. */
|
|
45
|
+
export const POPUP_EVENTS = new Set([
|
|
46
|
+
'ready', 'perf', 'log',
|
|
47
|
+
'event:bar.hovered', 'event:interval.changed', 'event:holder.selected', 'event:feed.selected',
|
|
48
|
+
'event:popup.close-request', 'event:popup.focus', 'event:popup.drag',
|
|
49
|
+
// task-1325 chart v2 requests (payloads validated by validateEventPayload below)
|
|
50
|
+
'event:chart.interval', 'event:chart.indicator', 'event:chart.style', 'event:chart.scale', 'event:chart.painted',
|
|
51
|
+
// task-1345 agent roster: per-agent pause / kick, by agent name (the only writes this popup can send)
|
|
52
|
+
'event:roster.pause', 'event:roster.resume', 'event:roster.kick',
|
|
53
|
+
// task-1355 order plans: cancel a plan by id (the ONLY write the plans popup can send — never arm/cap/sign)
|
|
54
|
+
'event:plan.cancel',
|
|
55
|
+
]);
|
|
56
|
+
/**
|
|
57
|
+
* task-1358 r3: the ONLY messages a pack widget's socket may send — the generic set every popup has. A widget socket
|
|
58
|
+
* is checked against this set, not POPUP_EVENTS, so `roster.kick` / `plan.cancel` / chart requests are dropped here
|
|
59
|
+
* (the TUI ignored them by popupId already; now the server does too, and never reports them as `popup.event`).
|
|
60
|
+
*/
|
|
61
|
+
export const WIDGET_EVENTS = new Set(['ready', 'perf', 'log', 'event:popup.close-request', 'event:popup.focus', 'event:popup.drag']);
|
|
62
|
+
/** The one route a widget wrapper may frame (an exact-path CSP source: the query string is not part of the match). */
|
|
63
|
+
export const WIDGET_FRAME_ROUTE = '/popup/widget-frame';
|
|
64
|
+
/** Agent-name shape for roster events (mirrors @dexterm/agents AGENT_NAME_RE; duplicated to avoid a package import). */
|
|
65
|
+
const ROSTER_AGENT_RE = /^[a-z][a-z0-9-]{0,23}$/;
|
|
66
|
+
/** Plan-id shape (`plan_<base36>_<base36>` from plan.ts); the TUI re-validates against its live list anyway. */
|
|
67
|
+
const PLAN_ID_RE = /^plan_[0-9a-z]+_[0-9a-z]+$/;
|
|
68
|
+
const isObj = (v) => !!v && typeof v === 'object' && !Array.isArray(v);
|
|
69
|
+
const oneOf = (set, v) => typeof v === 'string' && set.includes(v);
|
|
70
|
+
const finiteIn = (v, lo, hi) => typeof v === 'number' && Number.isFinite(v) && v >= lo && v <= hi;
|
|
71
|
+
const B58 = /^[1-9A-HJ-NP-Za-km-z]{32,44}$/;
|
|
72
|
+
/**
|
|
73
|
+
* task-1325: every allow-listed UI event's payload is checked against a closed schema and REBUILT from the
|
|
74
|
+
* validated fields only (extra keys are discarded). Returns null for anything else — the caller drops the
|
|
75
|
+
* message as `popup.dropped` / 'bad-payload' and never forwards or echoes the payload. Intervals must be one
|
|
76
|
+
* of the six chart timeframes, indicator ids one of CHART_INDICATORS, and so on.
|
|
77
|
+
*/
|
|
78
|
+
export function validateEventPayload(name, payload) {
|
|
79
|
+
const p = payload === undefined ? {} : payload;
|
|
80
|
+
if (!isObj(p))
|
|
81
|
+
return null;
|
|
82
|
+
switch (name) {
|
|
83
|
+
case 'bar.hovered':
|
|
84
|
+
case 'feed.selected':
|
|
85
|
+
return Number.isInteger(p.i) && finiteIn(p.i, 0, 1e6) ? { i: p.i } : null;
|
|
86
|
+
case 'interval.changed':
|
|
87
|
+
case 'chart.interval':
|
|
88
|
+
return oneOf(CHART_INTERVALS, p.interval) ? { interval: p.interval } : null;
|
|
89
|
+
case 'chart.indicator':
|
|
90
|
+
return oneOf(CHART_INDICATORS, p.id) && typeof p.on === 'boolean' ? { id: p.id, on: p.on } : null;
|
|
91
|
+
case 'chart.style':
|
|
92
|
+
return oneOf(CHART_STYLES, p.style) ? { style: p.style } : null;
|
|
93
|
+
case 'chart.scale':
|
|
94
|
+
return oneOf(CHART_SCALES, p.scale) ? { scale: p.scale } : null;
|
|
95
|
+
case 'chart.painted':
|
|
96
|
+
return (p.kind === 'first' || p.kind === 'data') && finiteIn(p.ms, 0, 600_000) && finiteIn(p.sentAt, 0, 1e15)
|
|
97
|
+
? { kind: p.kind, ms: p.ms, sentAt: p.sentAt } : null;
|
|
98
|
+
case 'holder.selected':
|
|
99
|
+
return typeof p.address === 'string' && B58.test(p.address) ? { address: p.address } : null;
|
|
100
|
+
case 'popup.close-request':
|
|
101
|
+
case 'popup.focus':
|
|
102
|
+
case 'popup.drag':
|
|
103
|
+
return {};
|
|
104
|
+
case 'roster.pause':
|
|
105
|
+
case 'roster.resume':
|
|
106
|
+
case 'roster.kick':
|
|
107
|
+
return typeof p.agent === 'string' && ROSTER_AGENT_RE.test(p.agent) ? { agent: p.agent } : null;
|
|
108
|
+
case 'plan.cancel':
|
|
109
|
+
return typeof p.id === 'string' && PLAN_ID_RE.test(p.id) ? { id: p.id } : null;
|
|
110
|
+
default:
|
|
111
|
+
return null;
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
/** sha256 CSP source for the exact `<style>` text the vendored LWC injects for its attribution logo, or null. */
|
|
115
|
+
export function lwcLogoStyleHash(lwcSource) {
|
|
116
|
+
const m = /\.innerText="(a#tv-attr-logo\{[^"\\]*)"/.exec(lwcSource);
|
|
117
|
+
return m ? `'sha256-${createHash('sha256').update(m[1], 'utf8').digest('base64')}'` : null;
|
|
118
|
+
}
|
|
119
|
+
// (r3) 'widget' is the WRAPPER page — ours, nonce-rendered like the rest; the pack html goes in its iframe
|
|
120
|
+
const PAGE_KINDS = ['chart', 'token', 'feeds', 'bubbles', 'roster', 'narrative', 'plans', 'widget'];
|
|
121
|
+
function b64url(buf) { return buf.toString('base64url'); }
|
|
122
|
+
function safeEq(a, b) {
|
|
123
|
+
const ba = Buffer.from(a), bb = Buffer.from(b);
|
|
124
|
+
return ba.length === bb.length && timingSafeEqual(ba, bb);
|
|
125
|
+
}
|
|
126
|
+
function sanitizeForLog(s, n = 80) { return String(s ?? '').replace(/[^\x20-\x7e]/g, '?').slice(0, n); }
|
|
127
|
+
const htmlEscape = (s) => s.replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]));
|
|
128
|
+
// ------------------------------------------------------------------ WebSocket (RFC 6455, server side, text frames)
|
|
129
|
+
const WS_GUID = '258EAFA5-E914-47DA-95CA-C5AB0DC85B11';
|
|
130
|
+
function wsEncode(str, opcode = 1) {
|
|
131
|
+
// A Buffer is sent as-is (close frames carry a raw 2-byte code). task-1325 fix: the close code used to be
|
|
132
|
+
// round-tripped through a latin1 string and re-encoded as UTF-8 (1000 → 03 c3 a8), which browsers reject
|
|
133
|
+
// as "a broken close frame containing invalid UTF-8".
|
|
134
|
+
const payload = typeof str === 'string' ? Buffer.from(str) : str;
|
|
135
|
+
let head;
|
|
136
|
+
if (payload.length < 126)
|
|
137
|
+
head = Buffer.from([0x80 | opcode, payload.length]);
|
|
138
|
+
else if (payload.length < 65536) {
|
|
139
|
+
head = Buffer.alloc(4);
|
|
140
|
+
head[0] = 0x80 | opcode;
|
|
141
|
+
head[1] = 126;
|
|
142
|
+
head.writeUInt16BE(payload.length, 2);
|
|
143
|
+
}
|
|
144
|
+
else {
|
|
145
|
+
head = Buffer.alloc(10);
|
|
146
|
+
head[0] = 0x80 | opcode;
|
|
147
|
+
head[1] = 127;
|
|
148
|
+
head.writeBigUInt64BE(BigInt(payload.length), 2);
|
|
149
|
+
}
|
|
150
|
+
return Buffer.concat([head, payload]);
|
|
151
|
+
}
|
|
152
|
+
class WsConn {
|
|
153
|
+
socket;
|
|
154
|
+
buf = Buffer.alloc(0);
|
|
155
|
+
closed = false;
|
|
156
|
+
onMessage;
|
|
157
|
+
onClose;
|
|
158
|
+
constructor(socket, onMessage, onClose) {
|
|
159
|
+
this.socket = socket;
|
|
160
|
+
this.onMessage = onMessage;
|
|
161
|
+
this.onClose = onClose;
|
|
162
|
+
socket.on('data', (d) => this.feed(d));
|
|
163
|
+
socket.on('close', () => this.end());
|
|
164
|
+
socket.on('error', () => this.end());
|
|
165
|
+
}
|
|
166
|
+
feed(d) {
|
|
167
|
+
this.buf = Buffer.concat([this.buf, d]);
|
|
168
|
+
for (;;) {
|
|
169
|
+
if (this.buf.length < 2)
|
|
170
|
+
return;
|
|
171
|
+
const b0 = this.buf[0], b1 = this.buf[1];
|
|
172
|
+
const opcode = b0 & 0x0f, masked = (b1 & 0x80) !== 0;
|
|
173
|
+
let len = b1 & 0x7f, off = 2;
|
|
174
|
+
if (len === 126) {
|
|
175
|
+
if (this.buf.length < 4)
|
|
176
|
+
return;
|
|
177
|
+
len = this.buf.readUInt16BE(2);
|
|
178
|
+
off = 4;
|
|
179
|
+
}
|
|
180
|
+
else if (len === 127) {
|
|
181
|
+
if (this.buf.length < 10)
|
|
182
|
+
return;
|
|
183
|
+
len = Number(this.buf.readBigUInt64BE(2));
|
|
184
|
+
off = 10;
|
|
185
|
+
}
|
|
186
|
+
if (!masked || len > MAX_WS_MSG) {
|
|
187
|
+
this.close(1009);
|
|
188
|
+
return;
|
|
189
|
+
} // clients MUST mask; cap size
|
|
190
|
+
if (this.buf.length < off + 4 + len)
|
|
191
|
+
return;
|
|
192
|
+
const mask = this.buf.subarray(off, off + 4);
|
|
193
|
+
const data = Buffer.from(this.buf.subarray(off + 4, off + 4 + len));
|
|
194
|
+
for (let i = 0; i < data.length; i++)
|
|
195
|
+
data[i] ^= mask[i & 3];
|
|
196
|
+
this.buf = this.buf.subarray(off + 4 + len);
|
|
197
|
+
if (opcode === 8) {
|
|
198
|
+
this.close(1000);
|
|
199
|
+
return;
|
|
200
|
+
}
|
|
201
|
+
if (opcode === 9) {
|
|
202
|
+
this.socket.write(wsEncode(data, 10));
|
|
203
|
+
continue;
|
|
204
|
+
}
|
|
205
|
+
if (opcode === 1)
|
|
206
|
+
this.onMessage(data.toString('utf8'));
|
|
207
|
+
// binary (2) and continuation frames are ignored on purpose: the protocol is JSON text only
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
send(obj) { if (!this.closed)
|
|
211
|
+
this.socket.write(wsEncode(JSON.stringify(obj))); }
|
|
212
|
+
close(code = 1000) {
|
|
213
|
+
if (this.closed)
|
|
214
|
+
return;
|
|
215
|
+
try {
|
|
216
|
+
const b = Buffer.alloc(2);
|
|
217
|
+
b.writeUInt16BE(code);
|
|
218
|
+
this.socket.write(wsEncode(b, 8));
|
|
219
|
+
}
|
|
220
|
+
catch { /* socket gone */ }
|
|
221
|
+
this.end();
|
|
222
|
+
}
|
|
223
|
+
end() { if (this.closed)
|
|
224
|
+
return; this.closed = true; try {
|
|
225
|
+
this.socket.destroy();
|
|
226
|
+
}
|
|
227
|
+
catch { /* ignore */ } this.onClose(); }
|
|
228
|
+
}
|
|
229
|
+
/** Start the loopback popup server. Resolves once it is listening on a random 127.0.0.1 port. */
|
|
230
|
+
export async function createPopupServer(opts = {}) {
|
|
231
|
+
const onEvent = opts.onEvent ?? (() => { });
|
|
232
|
+
const theme = opts.theme ?? {};
|
|
233
|
+
const rain = opts.rain ?? { enabled: true, speedScale: 1, density: 0.55 };
|
|
234
|
+
const pagesDir = opts.pagesDir ?? PAGES_DIR;
|
|
235
|
+
const tickets = new Map();
|
|
236
|
+
const wsTokens = new Map();
|
|
237
|
+
const frameTokens = new Map();
|
|
238
|
+
const popups = new Map();
|
|
239
|
+
const commonJs = readFileSync(path.join(pagesDir, 'common.js'), 'utf8');
|
|
240
|
+
const commonCss = readFileSync(path.join(pagesDir, 'common.css'), 'utf8');
|
|
241
|
+
// task-1358: the pack-widget SDK (window.dexterm), inlined ahead of the pack's own markup and allow-listed by its hash.
|
|
242
|
+
const widgetSdkJs = normalizeWidgetHtml(readFileSync(path.join(pagesDir, 'widget-sdk.js'), 'utf8'));
|
|
243
|
+
if (/<\/script[\s/>]/i.test(widgetSdkJs) || widgetSdkJs.includes('__NONCE__'))
|
|
244
|
+
throw new Error('widget-sdk.js must not close a script element or carry a nonce placeholder');
|
|
245
|
+
const optional = (f) => (existsSync(path.join(pagesDir, f)) ? readFileSync(path.join(pagesDir, f), 'utf8') : '');
|
|
246
|
+
const pages = {};
|
|
247
|
+
for (const kind of PAGE_KINDS) {
|
|
248
|
+
pages[kind] = {
|
|
249
|
+
html: readFileSync(path.join(pagesDir, `${kind}.html`), 'utf8'),
|
|
250
|
+
js: readFileSync(path.join(pagesDir, `${kind}.js`), 'utf8'),
|
|
251
|
+
// task-1325: optional per-page pure helpers (`chart-lib.js`) and stylesheet (`chart.css`), inlined under the nonce.
|
|
252
|
+
lib: optional(`${kind}-lib.js`),
|
|
253
|
+
css: optional(`${kind}.css`),
|
|
254
|
+
};
|
|
255
|
+
}
|
|
256
|
+
// task-1325: the vendored chart library is read ONCE into memory from a fixed path. The route below never
|
|
257
|
+
// maps a URL onto the filesystem, so there is nothing to traverse: any other path is a 404.
|
|
258
|
+
const lwcJs = readFileSync(path.join(LWC_DIR, LWC_FILE));
|
|
259
|
+
// The NOTICE text verbatim, its lines joined with " · "; the trailing URL becomes the page's link element.
|
|
260
|
+
const lwcNotice = readFileSync(path.join(LWC_DIR, 'NOTICE'), 'utf8').trim().replace(/\s*https:\/\/www\.tradingview\.com\/?$/, '').split(/\r?\n/).map((l) => l.trim()).filter(Boolean).join(' · ');
|
|
261
|
+
const lwcVersion = readFileSync(path.join(LWC_DIR, 'VERSION'), 'utf8').trim();
|
|
262
|
+
const lwcStyleHash = lwcLogoStyleHash(lwcJs.toString('utf8'));
|
|
263
|
+
let port = 0;
|
|
264
|
+
const reject = (req, res, reason, code = 404) => {
|
|
265
|
+
onEvent({ ev: 'probe.rejected', reason: reason, method: req.method ?? '', path: sanitizeForLog(req.url), remote: `${req.socket.remoteAddress}:${req.socket.remotePort}`, host: sanitizeForLog(req.headers.host), origin: sanitizeForLog(req.headers.origin) });
|
|
266
|
+
res.writeHead(code, { 'content-length': '0', 'cache-control': 'no-store' });
|
|
267
|
+
res.end();
|
|
268
|
+
};
|
|
269
|
+
const baseChecks = (req) => {
|
|
270
|
+
if (!LOOPBACK.has(req.socket.remoteAddress ?? ''))
|
|
271
|
+
return 'remote-not-loopback';
|
|
272
|
+
if (req.headers.host !== `127.0.0.1:${port}`)
|
|
273
|
+
return 'bad-host';
|
|
274
|
+
return null;
|
|
275
|
+
};
|
|
276
|
+
const originOk = (req) => req.headers.origin === `http://127.0.0.1:${port}`;
|
|
277
|
+
// task-1326: img-src opens exactly our own loopback image route (a path-scoped source), nothing else.
|
|
278
|
+
const imgSrc = () => (opts.imageProvider ? `http://127.0.0.1:${port}${IMAGE_ROUTE_PREFIX}` : "'none'");
|
|
279
|
+
const cspTail = () => `connect-src ws://127.0.0.1:${port}; img-src ${imgSrc()}; font-src 'none'; base-uri 'none'; form-action 'none'; frame-ancestors 'none'; object-src 'none'`;
|
|
280
|
+
// script-src stays nonce-only: the vendored chart library is an external same-origin <script src> that
|
|
281
|
+
// carries the page nonce, so no 'self' (and no other same-origin script) is needed. The chart page also
|
|
282
|
+
// allows exactly one more inline style by hash: the attribution-logo rule LWC injects (task-1325).
|
|
283
|
+
// (r3) the widget WRAPPER additionally names the one route its iframe may ever show — an exact path on our origin; the
|
|
284
|
+
// engine applies it to every navigation of the child, whoever initiates it, which is what keeps a pack page on loopback.
|
|
285
|
+
const pageCsp = (nonce, kind) => `default-src 'none'; script-src 'nonce-${nonce}'; style-src 'nonce-${nonce}'${kind === 'chart' && lwcStyleHash ? ` ${lwcStyleHash}` : ''}; ${cspTail()}${kind === 'widget' ? `; frame-src http://127.0.0.1:${port}${WIDGET_FRAME_ROUTE}` : ''}`;
|
|
286
|
+
/**
|
|
287
|
+
* task-1358 r2/r3: the pack page's CSP (the iframe document) has NO nonce — one hash per inline block (our SDK + the
|
|
288
|
+
* page's own), so no tag can borrow an authorisation and an external script can never match one. No 'strict-dynamic'
|
|
289
|
+
* (it would hand trust to anything a hashed script inserts). r3: the frame has NO network of its own (`connect-src
|
|
290
|
+
* 'none'`, no images, no fonts, no media — the wrapper owns the socket and feeds it by postMessage), may only be shown
|
|
291
|
+
* inside our origin (`frame-ancestors`), and is sandboxed WITHOUT allow-same-origin (opaque origin: no cookies, no
|
|
292
|
+
* storage, no reach into the wrapper). Trusted Types close the runtime sinks where implemented (Chromium) and the
|
|
293
|
+
* sandbox blocks popups, forms, downloads and top navigation; self-navigation is the parent's frame-src's job.
|
|
294
|
+
*/
|
|
295
|
+
const frameCsp = (scripts, styles) => `default-src 'none'; script-src ${scripts.join(' ')}; style-src ${styles.length ? styles.join(' ') : "'none'"}; connect-src 'none'; img-src 'none'; font-src 'none'; media-src 'none'; base-uri 'none'; form-action 'none'; frame-ancestors http://127.0.0.1:${port}; object-src 'none'; require-trusted-types-for 'script'; trusted-types 'none'; sandbox allow-scripts`;
|
|
296
|
+
const secHeaders = (csp) => ({
|
|
297
|
+
'content-type': 'text/html; charset=utf-8',
|
|
298
|
+
'cache-control': 'no-store',
|
|
299
|
+
'referrer-policy': 'no-referrer',
|
|
300
|
+
'x-content-type-options': 'nosniff',
|
|
301
|
+
'cross-origin-opener-policy': 'same-origin',
|
|
302
|
+
'cross-origin-resource-policy': 'same-origin',
|
|
303
|
+
'permissions-policy': 'camera=(), microphone=(), geolocation=(), payment=(), usb=()',
|
|
304
|
+
'content-security-policy': csp,
|
|
305
|
+
});
|
|
306
|
+
const renderPage = (kind, vars) => {
|
|
307
|
+
const nonce = b64url(randomBytes(16));
|
|
308
|
+
let html = pages[kind].html
|
|
309
|
+
.replace('/*__CSS__*/', () => commonCss + (pages[kind].css ? `\n${pages[kind].css}` : ''))
|
|
310
|
+
.replace('/*__COMMON_JS__*/', () => commonJs)
|
|
311
|
+
.replace('/*__LIB_JS__*/', () => pages[kind].lib)
|
|
312
|
+
.replace('/*__JS__*/', () => pages[kind].js)
|
|
313
|
+
.replaceAll('__NONCE__', nonce);
|
|
314
|
+
for (const [k, v] of Object.entries(vars))
|
|
315
|
+
html = html.replaceAll(`__${k}__`, htmlEscape(v));
|
|
316
|
+
return { html, nonce };
|
|
317
|
+
};
|
|
318
|
+
/**
|
|
319
|
+
* task-1358 (r2/r3): the pack page — the document inside the wrapper's iframe. The pack's html is served EXACTLY as
|
|
320
|
+
* installed (CR LF → LF only, as the parser itself does) — no placeholder of any kind is substituted into it, so nothing
|
|
321
|
+
* an author writes can borrow a value from the host. Our own <head> (the widget key + topics + the host origin the SDK
|
|
322
|
+
* talks to, and the SDK) is built here and spliced in ahead of the pack markup; the CSP then allow-lists every inline
|
|
323
|
+
* <script>/<style> of the FINAL document by sha256 (so the SDK's hash and the page's own come from one pass over what
|
|
324
|
+
* is actually sent). r3: NO socket token, popup id or port is in this document any more — the frame has no network and
|
|
325
|
+
* nothing worth stealing; the wrapper holds the credentials. Headers are ours: nothing in the pack html can change
|
|
326
|
+
* them. The audit ran at ticket issue (and at install and open); it runs once more here so this function is safe alone.
|
|
327
|
+
*/
|
|
328
|
+
const renderWidgetFrame = (w) => {
|
|
329
|
+
const problems = auditWidgetHtml(w.html);
|
|
330
|
+
if (problems.length)
|
|
331
|
+
throw new Error(`widget ${w.key}: page refused: ${problems.join('; ')}`);
|
|
332
|
+
const meta = (name, v) => `<meta name="${name}" content="${htmlEscape(v)}">`;
|
|
333
|
+
const head = meta('dexterm-widget', w.key) + meta('dexterm-topics', w.topics.join(',')) + meta('dexterm-host', `http://127.0.0.1:${port}`) + `<script>${widgetSdkJs}</script>`;
|
|
334
|
+
let html = normalizeWidgetHtml(w.html);
|
|
335
|
+
const i = html.search(/<\/head\s*>/i);
|
|
336
|
+
if (i >= 0)
|
|
337
|
+
html = html.slice(0, i) + head + html.slice(i);
|
|
338
|
+
else {
|
|
339
|
+
const j = html.search(/<body[\s>]/i);
|
|
340
|
+
html = j >= 0 ? html.slice(0, j) + `<head>${head}</head>` + html.slice(j) : `<!doctype html><html><head><meta charset="utf-8">${head}</head><body>${html}</body></html>`;
|
|
341
|
+
}
|
|
342
|
+
const { scripts, styles } = inlineHashes(html);
|
|
343
|
+
return { html, csp: frameCsp(scripts, styles) };
|
|
344
|
+
};
|
|
345
|
+
/** A single-use, 30 s frame token for the wrapper's iframe `src`; the pack page rides on the token, never in the wrapper. */
|
|
346
|
+
const issueFrameToken = (popupId, widget) => {
|
|
347
|
+
const f = b64url(randomBytes(32));
|
|
348
|
+
frameTokens.set(f, { popupId, widget, exp: Date.now() + TICKET_TTL_MS, used: false });
|
|
349
|
+
return `http://127.0.0.1:${port}${WIDGET_FRAME_ROUTE}?f=${f}`;
|
|
350
|
+
};
|
|
351
|
+
const server = http.createServer((req, res) => {
|
|
352
|
+
const bad = baseChecks(req);
|
|
353
|
+
if (bad)
|
|
354
|
+
return reject(req, res, bad);
|
|
355
|
+
const url = new URL(req.url ?? '/', `http://127.0.0.1:${port}`);
|
|
356
|
+
// ---- task-1358 r3: the pack page for a widget wrapper's iframe: GET /popup/widget-frame?f=<frame token>. Only an
|
|
357
|
+
// iframe may fetch it (Sec-Fetch-Dest, where the engine sends it — every current one does; a top-level open is refused),
|
|
358
|
+
// only from our own origin, and the token is single-use: a URL lifted from the wrapper is spent by the time anyone reads it.
|
|
359
|
+
if (req.method === 'GET' && url.pathname === WIDGET_FRAME_ROUTE) {
|
|
360
|
+
const dest = req.headers['sec-fetch-dest'], site = req.headers['sec-fetch-site'];
|
|
361
|
+
if ((dest !== undefined && dest !== 'iframe') || (site !== undefined && site !== 'same-origin'))
|
|
362
|
+
return reject(req, res, 'bad-frame');
|
|
363
|
+
const f = url.searchParams.get('f') ?? '';
|
|
364
|
+
const ft = frameTokens.get(f);
|
|
365
|
+
if (!ft || ft.used || ft.exp < Date.now())
|
|
366
|
+
return reject(req, res, 'bad-frame');
|
|
367
|
+
ft.used = true;
|
|
368
|
+
frameTokens.delete(f);
|
|
369
|
+
let page;
|
|
370
|
+
try {
|
|
371
|
+
page = renderWidgetFrame(ft.widget);
|
|
372
|
+
}
|
|
373
|
+
catch (e) {
|
|
374
|
+
onEvent({ ev: 'popup.log', popupId: ft.popupId, msg: sanitizeForLog(e.message, 200) });
|
|
375
|
+
return reject(req, res, 'bad-frame');
|
|
376
|
+
}
|
|
377
|
+
res.writeHead(200, secHeaders(page.csp));
|
|
378
|
+
return res.end(page.html);
|
|
379
|
+
}
|
|
380
|
+
// ---- popup page: GET /popup/<kind>?p=<ticket>
|
|
381
|
+
if (req.method === 'GET' && url.pathname.startsWith('/popup/')) {
|
|
382
|
+
const kind = url.pathname.slice('/popup/'.length);
|
|
383
|
+
const p = url.searchParams.get('p') ?? '';
|
|
384
|
+
const t = tickets.get(p);
|
|
385
|
+
if (!t || t.used || t.exp < Date.now() || t.kind !== kind || !pages[kind] || (kind === 'widget') !== !!t.widget)
|
|
386
|
+
return reject(req, res, 'bad-ticket');
|
|
387
|
+
t.used = true;
|
|
388
|
+
tickets.delete(p);
|
|
389
|
+
const wsToken = b64url(randomBytes(32));
|
|
390
|
+
wsTokens.set(wsToken, { popupId: t.popupId, kind, exp: Date.now() + TICKET_TTL_MS, used: false });
|
|
391
|
+
const vars = { WS_TOKEN: wsToken, POPUP_ID: t.popupId, PORT: String(port), LWC_NOTICE: lwcNotice, LWC_VERSION: lwcVersion, LWC_SRC: LWC_ROUTE };
|
|
392
|
+
if (kind === 'widget') {
|
|
393
|
+
// r3: the wrapper is OUR page; the pack html never enters it — the iframe fetches it with a one-shot frame token.
|
|
394
|
+
// The audit already refused loaders at ticket issue; a page that fails it again here never gets a frame token.
|
|
395
|
+
const problems = auditWidgetHtml(t.widget.html);
|
|
396
|
+
if (problems.length) {
|
|
397
|
+
onEvent({ ev: 'popup.log', popupId: t.popupId, msg: sanitizeForLog(`widget ${t.widget.key}: page refused: ${problems.join('; ')}`, 200) });
|
|
398
|
+
return reject(req, res, 'bad-ticket');
|
|
399
|
+
}
|
|
400
|
+
vars.WIDGET_KEY = t.widget.key;
|
|
401
|
+
vars.WIDGET_TITLE = t.widget.title;
|
|
402
|
+
vars.FRAME_URL = issueFrameToken(t.popupId, t.widget);
|
|
403
|
+
}
|
|
404
|
+
const r = renderPage(kind, vars);
|
|
405
|
+
res.writeHead(200, secHeaders(pageCsp(r.nonce, kind)));
|
|
406
|
+
return res.end(r.html);
|
|
407
|
+
}
|
|
408
|
+
// ---- task-1325: the vendored chart library. Exactly one path, GET only, no query string; served from
|
|
409
|
+
// memory (never a filesystem lookup), so `..`, encoded variants and every other file are a 404.
|
|
410
|
+
if (req.method === 'GET' && req.url === LWC_ROUTE) {
|
|
411
|
+
res.writeHead(200, {
|
|
412
|
+
'content-type': 'text/javascript; charset=utf-8',
|
|
413
|
+
'content-length': String(lwcJs.length),
|
|
414
|
+
'cache-control': 'no-cache',
|
|
415
|
+
'x-content-type-options': 'nosniff',
|
|
416
|
+
'cross-origin-resource-policy': 'same-origin',
|
|
417
|
+
'referrer-policy': 'no-referrer',
|
|
418
|
+
});
|
|
419
|
+
return res.end(lwcJs);
|
|
420
|
+
}
|
|
421
|
+
// ---- task-1326: a coin image from the local cache. Exact shape only (32 hex + .png, no query string); the
|
|
422
|
+
// key never touches the filesystem path (the provider maps it to its own cache), so there is nothing to traverse.
|
|
423
|
+
if (req.method === 'GET' && opts.imageProvider && req.url && IMAGE_ROUTE_RE.test(req.url)) {
|
|
424
|
+
const key = IMAGE_ROUTE_RE.exec(req.url)[1];
|
|
425
|
+
let png = null;
|
|
426
|
+
try {
|
|
427
|
+
png = opts.imageProvider(key);
|
|
428
|
+
}
|
|
429
|
+
catch {
|
|
430
|
+
png = null;
|
|
431
|
+
}
|
|
432
|
+
if (!png)
|
|
433
|
+
return reject(req, res, 'unknown-route');
|
|
434
|
+
res.writeHead(200, {
|
|
435
|
+
'content-type': 'image/png',
|
|
436
|
+
'content-length': String(png.byteLength),
|
|
437
|
+
'cache-control': 'private, max-age=300',
|
|
438
|
+
'x-content-type-options': 'nosniff',
|
|
439
|
+
'cross-origin-resource-policy': 'same-origin',
|
|
440
|
+
'referrer-policy': 'no-referrer',
|
|
441
|
+
});
|
|
442
|
+
return res.end(Buffer.from(png.buffer, png.byteOffset, png.byteLength));
|
|
443
|
+
}
|
|
444
|
+
return reject(req, res, 'unknown-route');
|
|
445
|
+
});
|
|
446
|
+
server.on('upgrade', (req, socket, head) => {
|
|
447
|
+
const fail = (reason) => {
|
|
448
|
+
onEvent({ ev: 'probe.rejected', reason: reason, method: 'UPGRADE', path: sanitizeForLog(req.url), remote: `${req.socket.remoteAddress}:${req.socket.remotePort}`, host: sanitizeForLog(req.headers.host), origin: sanitizeForLog(req.headers.origin) });
|
|
449
|
+
socket.write('HTTP/1.1 404 Not Found\r\ncontent-length: 0\r\nconnection: close\r\n\r\n');
|
|
450
|
+
socket.destroy();
|
|
451
|
+
};
|
|
452
|
+
const bad = baseChecks(req);
|
|
453
|
+
if (bad)
|
|
454
|
+
return fail(bad);
|
|
455
|
+
if (!originOk(req))
|
|
456
|
+
return fail('bad-origin');
|
|
457
|
+
const url = new URL(req.url ?? '/', `http://127.0.0.1:${port}`);
|
|
458
|
+
if (url.pathname !== '/ws')
|
|
459
|
+
return fail('unknown-route');
|
|
460
|
+
const tok = url.searchParams.get('t') ?? '';
|
|
461
|
+
const match = [...wsTokens.entries()].find(([k]) => safeEq(k, tok));
|
|
462
|
+
const entry = match?.[1];
|
|
463
|
+
if (!entry || entry.used || entry.exp < Date.now())
|
|
464
|
+
return fail('bad-ws-token');
|
|
465
|
+
entry.used = true;
|
|
466
|
+
wsTokens.delete(match[0]);
|
|
467
|
+
if (String(req.headers['sec-websocket-version']) !== '13' || !req.headers['sec-websocket-key'])
|
|
468
|
+
return fail('bad-ws-handshake');
|
|
469
|
+
const accept = createHash('sha1').update(req.headers['sec-websocket-key'] + WS_GUID).digest('base64');
|
|
470
|
+
socket.write(`HTTP/1.1 101 Switching Protocols\r\nUpgrade: websocket\r\nConnection: Upgrade\r\nSec-WebSocket-Accept: ${accept}\r\n\r\n`);
|
|
471
|
+
const popupId = entry.popupId;
|
|
472
|
+
const popup = popups.get(popupId) ?? { kind: entry.kind, params: {}, openedAt: Date.now(), conn: null };
|
|
473
|
+
const conn = new WsConn(socket, (text) => {
|
|
474
|
+
let msg;
|
|
475
|
+
try {
|
|
476
|
+
msg = JSON.parse(text);
|
|
477
|
+
}
|
|
478
|
+
catch {
|
|
479
|
+
onEvent({ ev: 'popup.dropped', popupId, reason: 'bad-json' });
|
|
480
|
+
return;
|
|
481
|
+
}
|
|
482
|
+
const key = msg.type === 'event' ? `event:${String(msg.name)}` : msg.type;
|
|
483
|
+
// task-1325: an unknown name is never echoed back (not even sanitised) — the TUI only learns it was dropped.
|
|
484
|
+
// task-1358 r3: a widget's socket gets the generic set only (never roster / plan / chart events).
|
|
485
|
+
const allowed = popup.kind === 'widget' ? WIDGET_EVENTS : POPUP_EVENTS;
|
|
486
|
+
if (typeof key !== 'string' || !allowed.has(key)) {
|
|
487
|
+
onEvent({ ev: 'popup.dropped', popupId, reason: 'not-allowed', type: msg.type === 'event' ? 'event:?' : '?' });
|
|
488
|
+
return;
|
|
489
|
+
}
|
|
490
|
+
if (msg.type === 'ready') {
|
|
491
|
+
popup.readyAt = Date.now();
|
|
492
|
+
onEvent({ ev: 'popup.ready', popupId, kind: popup.kind, openToReadyMs: popup.readyAt - popup.openedAt, page: sanitizeForLog(msg.page, 24) });
|
|
493
|
+
if (popup.data)
|
|
494
|
+
conn.send({ type: 'data', topic: popup.kind, payload: popup.data });
|
|
495
|
+
return;
|
|
496
|
+
}
|
|
497
|
+
if (msg.type === 'perf') {
|
|
498
|
+
onEvent({ ev: 'popup.perf', popupId, ...pickPerf(msg) });
|
|
499
|
+
return;
|
|
500
|
+
}
|
|
501
|
+
if (msg.type === 'log') {
|
|
502
|
+
onEvent({ ev: 'popup.log', popupId, msg: sanitizeForLog(msg.msg, 200) });
|
|
503
|
+
return;
|
|
504
|
+
}
|
|
505
|
+
// task-1325: the payload must match the event's closed schema; junk is dropped and never echoed
|
|
506
|
+
// (only the already allow-listed event NAME is reported).
|
|
507
|
+
const payload = validateEventPayload(String(msg.name), msg.payload);
|
|
508
|
+
if (!payload) {
|
|
509
|
+
onEvent({ ev: 'popup.dropped', popupId, reason: 'bad-payload', type: sanitizeForLog(key, 40) });
|
|
510
|
+
return;
|
|
511
|
+
}
|
|
512
|
+
onEvent({ ev: 'popup.event', popupId, name: String(msg.name), payload });
|
|
513
|
+
}, () => { popups.delete(popupId); onEvent({ ev: 'popup.closed', popupId }); });
|
|
514
|
+
popup.conn = conn;
|
|
515
|
+
popups.set(popupId, popup);
|
|
516
|
+
// params minus the initial view model (that one is pushed as `data` right after `ready`; task-1325: a
|
|
517
|
+
// 1000-bar chart VM was being sent twice). `fallback` carries the browser-fallback reason for the page.
|
|
518
|
+
// (task-1358: a widget ticket's params hold only {key, title, topics}; the html lives on the ticket, never here)
|
|
519
|
+
const { data: _initial, ...helloParams } = popup.params;
|
|
520
|
+
conn.send({ type: 'hello', protocol: PROTOCOL_VERSION, popup: { id: popupId, kind: popup.kind, params: helloParams }, theme, rain });
|
|
521
|
+
if (head && head.length)
|
|
522
|
+
conn.feed(head);
|
|
523
|
+
});
|
|
524
|
+
// keep connection parsing strict and quick
|
|
525
|
+
server.headersTimeout = 5000;
|
|
526
|
+
server.requestTimeout = 10_000;
|
|
527
|
+
server.keepAliveTimeout = 5000;
|
|
528
|
+
server.maxHeadersCount = 40;
|
|
529
|
+
await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve));
|
|
530
|
+
port = server.address().port;
|
|
531
|
+
function broadcast(msg, kind = null) { for (const p of popups.values())
|
|
532
|
+
if (p.conn && (!kind || p.kind === kind))
|
|
533
|
+
p.conn.send(msg); }
|
|
534
|
+
function pickPerf(m) {
|
|
535
|
+
return { fps: +m.fps || 0, frameMs: +m.frameMs || 0, p95Ms: +m.p95Ms || 0, long: +m.long || 0, dpr: +m.dpr || 0, w: +m.w || 0, h: +m.h || 0, pwned: String(m.pwned) };
|
|
536
|
+
}
|
|
537
|
+
return {
|
|
538
|
+
port,
|
|
539
|
+
origin: `http://127.0.0.1:${port}`,
|
|
540
|
+
issueTicket(kind, popupId, params = {}) {
|
|
541
|
+
const ticket = b64url(randomBytes(32));
|
|
542
|
+
// task-1358: a widget ticket carries the pack page; it is validated here (shape, size, topics) and kept on the
|
|
543
|
+
// ticket only. The page's `widget` param in hello is the key/title/topics, never the html.
|
|
544
|
+
let widget;
|
|
545
|
+
if (kind === 'widget') {
|
|
546
|
+
const w = params.widget;
|
|
547
|
+
if (!w || typeof w.key !== 'string' || !/^[a-z][a-z0-9-]{0,39}\/[a-z][a-z0-9-]{0,39}(\/[a-z][a-z0-9-]{0,39})?$/.test(w.key) || typeof w.html !== 'string' || !Array.isArray(w.topics))
|
|
548
|
+
throw new Error('widget ticket needs { key, title, topics, html }');
|
|
549
|
+
if (w.html.length > WIDGET_HTML_MAX)
|
|
550
|
+
throw new Error(`widget ${w.key}: page is ${w.html.length} chars (max ${WIDGET_HTML_MAX})`);
|
|
551
|
+
// r2: a page that carries a loader (the __NONCE__ token, <script src>, <link>, a frame, <meta http-equiv>, @import)
|
|
552
|
+
// never gets a ticket — the last of the three refusals (install, open, here), so the server is safe on its own
|
|
553
|
+
const problems = auditWidgetHtml(w.html);
|
|
554
|
+
if (problems.length)
|
|
555
|
+
throw new Error(`widget ${w.key}: page refused: ${problems.join('; ')}`);
|
|
556
|
+
const topics = w.topics.filter((t) => WIDGET_TOPICS.includes(t));
|
|
557
|
+
widget = { key: w.key, title: String(w.title ?? w.key).slice(0, 60), topics, html: w.html };
|
|
558
|
+
params = { ...params, widget: { key: widget.key, title: widget.title, topics } };
|
|
559
|
+
}
|
|
560
|
+
else if (params.widget !== undefined)
|
|
561
|
+
throw new Error(`a ${kind} ticket cannot carry a widget page`);
|
|
562
|
+
tickets.set(ticket, { popupId, kind, params, exp: Date.now() + TICKET_TTL_MS, used: false, ...(widget ? { widget } : {}) });
|
|
563
|
+
popups.set(popupId, { kind, params, openedAt: Date.now(), data: params.data, conn: popups.get(popupId)?.conn ?? null });
|
|
564
|
+
return `http://127.0.0.1:${port}/popup/${kind}?p=${ticket}`;
|
|
565
|
+
},
|
|
566
|
+
send(popupId, msg) { popups.get(popupId)?.conn?.send(msg); },
|
|
567
|
+
sendData(popupId, topic, payload) { const p = popups.get(popupId); if (p && p.kind === 'widget' && WIDGET_TOPICS.includes(topic))
|
|
568
|
+
p.conn?.send({ type: 'data', topic, payload }); },
|
|
569
|
+
broadcast,
|
|
570
|
+
setData(popupId, data) { const p = popups.get(popupId); if (p) {
|
|
571
|
+
p.data = data;
|
|
572
|
+
p.conn?.send({ type: 'data', topic: p.kind, payload: data });
|
|
573
|
+
} },
|
|
574
|
+
closePopup(popupId) { const p = popups.get(popupId); if (p?.conn) {
|
|
575
|
+
p.conn.send({ type: 'close' });
|
|
576
|
+
p.conn.close(1000);
|
|
577
|
+
} popups.delete(popupId); },
|
|
578
|
+
popups,
|
|
579
|
+
close() { for (const p of popups.values())
|
|
580
|
+
p.conn?.close(1001); return new Promise((r) => server.close(() => r())); },
|
|
581
|
+
};
|
|
582
|
+
}
|