Spaces:
Running
Running
Submesh 0.5 - header carry + shared submesh freq + op_count + Scan UI + exo-structure docs
Browse files- PAPER.md +69 -0
- core/submesh.py +400 -67
PAPER.md
CHANGED
|
@@ -340,3 +340,72 @@ assignment as the natural next evolution.
|
|
| 340 |
---
|
| 341 |
|
| 342 |
*Submitted for review. Benchmark code and implementation: https://github.com/Ig0tU/SignalMesh*
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 340 |
---
|
| 341 |
|
| 342 |
*Submitted for review. Benchmark code and implementation: https://github.com/Ig0tU/SignalMesh*
|
| 343 |
+
|
| 344 |
+
|
| 345 |
+
---
|
| 346 |
+
|
| 347 |
+
## Addendum: Runtime Without The Runtime
|
| 348 |
+
|
| 349 |
+
### Exo-Structure
|
| 350 |
+
|
| 351 |
+
The Submesh Protocol wraps any origin — a URL, a hosted web app, later a local
|
| 352 |
+
binary or repo — from the OUTSIDE. Nothing is embedded, hosted, or shipped. The
|
| 353 |
+
target service is untouched; only its outside gets a handle. The mesh points
|
| 354 |
+
AT the runtime, wearing it like a glove.
|
| 355 |
+
|
| 356 |
+
We call this pattern **exo-structure**: an external skeleton around software.
|
| 357 |
+
It gives you coord-addressable handles into a service's behavior without
|
| 358 |
+
containing the service itself. Docker, K8s, Lambda, and WASM all EMBED runtime —
|
| 359 |
+
you host, ship, permission, and update the thing. Submesh does the opposite:
|
| 360 |
+
it points AT the thing from outside, at zero cost to the target. GitHub
|
| 361 |
+
doesn't know it's wrapped. Hyperagent won't either.
|
| 362 |
+
|
| 363 |
+
### State plane, Execution plane, Auth plane — all exo
|
| 364 |
+
|
| 365 |
+
SignalMesh already inverted STATE — context without a context server,
|
| 366 |
+
broadcast/tune without a message broker, coordinates without a service
|
| 367 |
+
registry. That was exo-structure for state.
|
| 368 |
+
|
| 369 |
+
The Submesh extends the inversion to EXECUTION. A runtime's behavior is
|
| 370 |
+
available without ever running it: `POST /api/submesh/wrap` reads the target's
|
| 371 |
+
`/openapi.json` (or accepts a user-provided manifest) and emits one coord per
|
| 372 |
+
operation. `POST /api/submesh/call` piggybacks that coord to invoke the real
|
| 373 |
+
service and streams the result envelope back onto the mesh — where any tuned
|
| 374 |
+
agent receives it without polling.
|
| 375 |
+
|
| 376 |
+
Auth is exo too. `POST /api/submesh/session` attaches cookies AND/OR headers
|
| 377 |
+
scoped to an origin. On `/call`, the mesh injects them into the outbound
|
| 378 |
+
request — same trust boundary as an autofilled Submit Payment: user-triggered,
|
| 379 |
+
local-side, and the credential the target already accepted IS the credential.
|
| 380 |
+
Values live in memory only; only 8-char hashes are retained for provenance.
|
| 381 |
+
|
| 382 |
+
### Fractal
|
| 383 |
+
|
| 384 |
+
Every wrapped origin is itself reachable through the mesh, and can in turn
|
| 385 |
+
host its own submesh, which can be wrapped, ad infinitum. The self-wrap demo
|
| 386 |
+
in Phase 0 proved this: the SignalMesh Space wrapped itself, generated 54
|
| 387 |
+
coord-handles into its own routes, and called back into itself through those
|
| 388 |
+
coords. Exo on exo. No new infrastructure at any layer.
|
| 389 |
+
|
| 390 |
+
### Contract
|
| 391 |
+
|
| 392 |
+
- Discovery: `POST /api/submesh/wrap { origin, cookies?, headers?, manifest? }` returns coord list.
|
| 393 |
+
- Session attach: `POST /api/submesh/session { origin, cookies?, headers? }` scopes credentials to origin.
|
| 394 |
+
- Invoke: `POST /api/submesh/call { coord, input, cookies?, headers? }` executes a real HTTP round-trip and broadcasts the result envelope.
|
| 395 |
+
- Ambient reception: `GET /api/submesh/stream` (SSE) or `POST /api/tune_in { keywords: ["submesh"] }`.
|
| 396 |
+
- Introspection: `GET /api/submesh/nodes`, `GET /api/submesh/health`.
|
| 397 |
+
- Scan UI: `GET /api/submesh/ui` — paste-based session sync page.
|
| 398 |
+
|
| 399 |
+
All under the existing `X-SignalMesh-Key` gate. Free tier: `smesh-free-demo`
|
| 400 |
+
(200 calls / 24h). Paid: personal key, unlimited.
|
| 401 |
+
|
| 402 |
+
### What this unlocks
|
| 403 |
+
|
| 404 |
+
- Any hosted service becomes agent-callable through the SAME code path as any
|
| 405 |
+
other — no per-service adapter written by hand.
|
| 406 |
+
- A user's authenticated browsing surface becomes an agent's callable surface:
|
| 407 |
+
same threads, same repos, same private views, no re-auth ritual.
|
| 408 |
+
- Fleet-scale ambient sharing of every call result: one agent calls, all tuned
|
| 409 |
+
agents hear the envelope, no polling.
|
| 410 |
+
- The service-integration long tail collapses to a Scan click and a POST.
|
| 411 |
+
|
core/submesh.py
CHANGED
|
@@ -1,15 +1,42 @@
|
|
| 1 |
"""
|
| 2 |
-
SignalMesh — Submesh Protocol
|
| 3 |
-
|
| 4 |
-
|
| 5 |
-
|
| 6 |
-
|
| 7 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 8 |
|
| 9 |
Additive only: no changes to existing broadcast/ingest/manifest handlers.
|
| 10 |
Mounted from signalmesh_api.py via `include_router(make_router(...))`.
|
| 11 |
|
| 12 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 13 |
"""
|
| 14 |
from __future__ import annotations
|
| 15 |
|
|
@@ -23,13 +50,16 @@ from urllib.parse import urlparse
|
|
| 23 |
|
| 24 |
import httpx
|
| 25 |
from fastapi import APIRouter, Header, HTTPException
|
| 26 |
-
from fastapi.responses import StreamingResponse
|
| 27 |
from pydantic import BaseModel
|
| 28 |
|
|
|
|
|
|
|
|
|
|
| 29 |
# ── In-memory Phase-0 stores (SQLite promotion planned for Phase 1) ──────────
|
| 30 |
_NODES: Dict[str, dict] = {} # origin_slug -> node record
|
| 31 |
_COORDS: Dict[str, dict] = {} # coord -> {origin_slug, op}
|
| 32 |
-
_SESSIONS: Dict[str, dict] = {} # origin_slug -> {cookies, expires,
|
| 33 |
_LOG: List[dict] = [] # rolling event log for /stream
|
| 34 |
_LOG_MAX = 500
|
| 35 |
|
|
@@ -39,6 +69,7 @@ class WrapReq(BaseModel):
|
|
| 39 |
origin: str
|
| 40 |
kind: str = "url" # "url" | "manifest" | "nicoli" (Phase 0.5)
|
| 41 |
cookies: Any = None # list[dict] | dict | JSON str | Netscape text
|
|
|
|
| 42 |
manifest: Optional[dict] = None
|
| 43 |
scan_openapi: bool = True # try /openapi.json when kind=url
|
| 44 |
|
|
@@ -47,11 +78,13 @@ class CallReq(BaseModel):
|
|
| 47 |
coord: str
|
| 48 |
input: dict = {}
|
| 49 |
cookies: Any = None # per-call override; else pulls from session store
|
|
|
|
| 50 |
|
| 51 |
|
| 52 |
class SessionReq(BaseModel):
|
| 53 |
origin: str
|
| 54 |
-
cookies: Any
|
|
|
|
| 55 |
ttl_ms: int = 24 * 3600 * 1000 # default 24h
|
| 56 |
|
| 57 |
# ── Helpers ───────────────────────────────────────────────────────────────────
|
|
@@ -100,10 +133,43 @@ def _normalize_cookies(raw: Any) -> List[dict]:
|
|
| 100 |
return []
|
| 101 |
|
| 102 |
|
| 103 |
-
def
|
| 104 |
-
"""
|
| 105 |
-
|
| 106 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 107 |
|
| 108 |
|
| 109 |
async def _fetch_openapi(base: str) -> Optional[dict]:
|
|
@@ -175,6 +241,252 @@ def _default_url_ops(slug: str, base: str) -> List[dict]:
|
|
| 175 |
"requires_body": False,
|
| 176 |
}]
|
| 177 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 178 |
# ── Router factory ────────────────────────────────────────────────────────────
|
| 179 |
|
| 180 |
def make_router(*, check_key, signal_registry, sec_omega) -> APIRouter:
|
|
@@ -196,6 +508,11 @@ def make_router(*, check_key, signal_registry, sec_omega) -> APIRouter:
|
|
| 196 |
"events": len(_LOG),
|
| 197 |
}
|
| 198 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 199 |
@router.post("/wrap", summary="Wrap an origin into coordinate-addressable tools")
|
| 200 |
async def wrap(req: WrapReq, x_signalmesh_key: Optional[str] = Header(default=None)):
|
| 201 |
check_key(x_signalmesh_key)
|
|
@@ -230,48 +547,51 @@ def make_router(*, check_key, signal_registry, sec_omega) -> APIRouter:
|
|
| 230 |
ops = _default_url_ops(slug, base)
|
| 231 |
source = "default"
|
| 232 |
|
| 233 |
-
# Wrap-time
|
| 234 |
cookies = _normalize_cookies(req.cookies)
|
| 235 |
-
|
| 236 |
-
|
| 237 |
-
|
| 238 |
-
"expires": time.time() + 86400,
|
| 239 |
-
"hashes": _cookie_hashes(cookies),
|
| 240 |
-
}
|
| 241 |
|
| 242 |
_NODES[slug] = {
|
| 243 |
"origin": origin,
|
| 244 |
"base": base,
|
| 245 |
"slug": slug,
|
| 246 |
"ops": ops,
|
|
|
|
| 247 |
"source": source,
|
| 248 |
-
"authed": bool(cookies),
|
| 249 |
"wrapped_at": time.time(),
|
| 250 |
}
|
| 251 |
for op in ops:
|
| 252 |
_COORDS[op["coord"]] = {"origin_slug": slug, "op": op}
|
| 253 |
|
| 254 |
-
# Broadcast node registration
|
| 255 |
-
|
| 256 |
-
signal_registry
|
| 257 |
-
|
| 258 |
-
|
| 259 |
-
|
| 260 |
-
|
| 261 |
-
|
| 262 |
-
|
| 263 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 264 |
|
| 265 |
_log({"event": "wrap", "slug": slug, "source": source,
|
| 266 |
-
"op_count": len(ops), "authed": bool(cookies)})
|
| 267 |
|
| 268 |
return {
|
| 269 |
"wrapped": True,
|
| 270 |
"origin": origin,
|
| 271 |
"slug": slug,
|
| 272 |
"source": source,
|
| 273 |
-
"authed": bool(cookies),
|
| 274 |
-
"cookie_hashes":
|
|
|
|
| 275 |
"coords": [o["coord"] for o in ops],
|
| 276 |
"op_count": len(ops),
|
| 277 |
}
|
|
@@ -281,29 +601,31 @@ def make_router(*, check_key, signal_registry, sec_omega) -> APIRouter:
|
|
| 281 |
check_key(x_signalmesh_key)
|
| 282 |
return {"nodes": list(_NODES.values()), "count": len(_NODES)}
|
| 283 |
|
| 284 |
-
@router.post("/session",
|
|
|
|
| 285 |
def session(req: SessionReq, x_signalmesh_key: Optional[str] = Header(default=None)):
|
| 286 |
check_key(x_signalmesh_key)
|
| 287 |
cookies = _normalize_cookies(req.cookies)
|
| 288 |
-
|
| 289 |
-
|
|
|
|
| 290 |
slug = _slug(req.origin)
|
| 291 |
-
_SESSIONS[slug] =
|
| 292 |
-
"cookies": cookies,
|
| 293 |
-
"expires": time.time() + req.ttl_ms / 1000.0,
|
| 294 |
-
"hashes": _cookie_hashes(cookies),
|
| 295 |
-
}
|
| 296 |
if slug in _NODES:
|
| 297 |
_NODES[slug]["authed"] = True
|
| 298 |
-
_log({"event": "session_attached", "slug": slug,
|
|
|
|
| 299 |
return {
|
| 300 |
"attached": True,
|
| 301 |
"slug": slug,
|
| 302 |
"cookie_count": len(cookies),
|
| 303 |
-
"
|
|
|
|
|
|
|
|
|
|
| 304 |
}
|
| 305 |
|
| 306 |
-
@router.post("/call", summary="Invoke a submesh coord with
|
| 307 |
async def call(req: CallReq, x_signalmesh_key: Optional[str] = Header(default=None)):
|
| 308 |
check_key(x_signalmesh_key)
|
| 309 |
entry = _COORDS.get(req.coord)
|
|
@@ -325,12 +647,15 @@ def make_router(*, check_key, signal_registry, sec_omega) -> APIRouter:
|
|
| 325 |
if not safe:
|
| 326 |
raise HTTPException(400, f"SEC-Ω blocked: {reason}")
|
| 327 |
|
| 328 |
-
# Resolve cookies: per-call override > stored session > none
|
| 329 |
cookies = _normalize_cookies(req.cookies) if req.cookies else []
|
| 330 |
-
if
|
| 331 |
-
|
| 332 |
-
|
| 333 |
-
|
|
|
|
|
|
|
|
|
|
| 334 |
|
| 335 |
method = op["method"]
|
| 336 |
url = op["url"]
|
|
@@ -354,10 +679,17 @@ def make_router(*, check_key, signal_registry, sec_omega) -> APIRouter:
|
|
| 354 |
except Exception:
|
| 355 |
continue
|
| 356 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 357 |
started = time.time()
|
| 358 |
try:
|
| 359 |
-
async with httpx.AsyncClient(follow_redirects=True, timeout=20.0,
|
| 360 |
-
|
| 361 |
r = await client.request(method, url, params=params, json=body)
|
| 362 |
ct = r.headers.get("content-type", "")
|
| 363 |
if "application/json" in ct:
|
|
@@ -368,8 +700,9 @@ def make_router(*, check_key, signal_registry, sec_omega) -> APIRouter:
|
|
| 368 |
"coord": req.coord,
|
| 369 |
"origin_slug": slug,
|
| 370 |
"status": r.status_code,
|
| 371 |
-
"authed": bool(cookies),
|
| 372 |
-
"cookie_hashes":
|
|
|
|
| 373 |
"payload_type": payload_type,
|
| 374 |
"payload": payload,
|
| 375 |
"elapsed_ms": int((time.time() - started) * 1000),
|
|
@@ -381,23 +714,23 @@ def make_router(*, check_key, signal_registry, sec_omega) -> APIRouter:
|
|
| 381 |
"origin_slug": slug,
|
| 382 |
"status": 0,
|
| 383 |
"error": str(e),
|
| 384 |
-
"authed": bool(cookies),
|
| 385 |
"elapsed_ms": int((time.time() - started) * 1000),
|
| 386 |
}
|
| 387 |
|
| 388 |
-
# Broadcast
|
| 389 |
-
|
| 390 |
-
signal_registry
|
| 391 |
-
|
| 392 |
-
|
| 393 |
-
|
| 394 |
-
|
| 395 |
-
|
| 396 |
-
|
| 397 |
-
pass
|
| 398 |
|
| 399 |
_log({"event": "call", "coord": req.coord,
|
| 400 |
-
"status": envelope.get("status"),
|
|
|
|
| 401 |
|
| 402 |
return envelope
|
| 403 |
|
|
|
|
| 1 |
"""
|
| 2 |
+
SignalMesh — Submesh Protocol
|
| 3 |
+
Exo-structure: a runtime's behavior without ever running it.
|
| 4 |
+
|
| 5 |
+
The Submesh wraps any origin (URL / web-app; later: local binary / repo via
|
| 6 |
+
nicoli) into coordinate-addressable tools on the mesh. Nothing is embedded,
|
| 7 |
+
hosted, or shipped — the target service is untouched; only its outside gets
|
| 8 |
+
a handle. The mesh points AT the runtime from outside, wearing it like a
|
| 9 |
+
glove.
|
| 10 |
+
|
| 11 |
+
Auth carry (cookies + headers) is how the exo-structure inherits the user's
|
| 12 |
+
reach — its skin is the browser's skin. Same trust model as an autofilled
|
| 13 |
+
Submit Payment: user-triggered, local-side, and the credential the target
|
| 14 |
+
already accepted IS the credential.
|
| 15 |
+
|
| 16 |
+
SignalMesh already inverted STATE (context without a context server,
|
| 17 |
+
broadcast/tune without a message broker, coordinates without a service
|
| 18 |
+
registry). The Submesh extends the same inversion to EXECUTION — you get
|
| 19 |
+
a runtime's behavior without ever running it. State plane exo, execution
|
| 20 |
+
plane exo, auth plane exo. Full skeleton, no viscera.
|
| 21 |
+
|
| 22 |
+
Contrast: Docker / K8s / Lambda / WASM all EMBED runtime — you host, ship,
|
| 23 |
+
permission, and update the thing. Submesh points AT the thing from outside,
|
| 24 |
+
at zero cost to the target. GitHub doesn't know it's wrapped. Hyperagent
|
| 25 |
+
won't either.
|
| 26 |
|
| 27 |
Additive only: no changes to existing broadcast/ingest/manifest handlers.
|
| 28 |
Mounted from signalmesh_api.py via `include_router(make_router(...))`.
|
| 29 |
|
| 30 |
+
Endpoints:
|
| 31 |
+
GET /api/submesh/health — liveness + counts
|
| 32 |
+
GET /api/submesh/ui — paste-based Scan UI (attach session)
|
| 33 |
+
POST /api/submesh/wrap — wrap an origin, emit coord list
|
| 34 |
+
GET /api/submesh/nodes — list wrapped nodes
|
| 35 |
+
POST /api/submesh/session — attach cookies AND/OR headers to an origin
|
| 36 |
+
POST /api/submesh/call — invoke a coord with session carry
|
| 37 |
+
GET /api/submesh/stream — SSE stream of events
|
| 38 |
+
|
| 39 |
+
Author: Ig0tU (SignalMesh) — Submesh Protocol
|
| 40 |
"""
|
| 41 |
from __future__ import annotations
|
| 42 |
|
|
|
|
| 50 |
|
| 51 |
import httpx
|
| 52 |
from fastapi import APIRouter, Header, HTTPException
|
| 53 |
+
from fastapi.responses import HTMLResponse, StreamingResponse
|
| 54 |
from pydantic import BaseModel
|
| 55 |
|
| 56 |
+
# ── Shared broadcast frequency for keyword tune-in ────────────────────────────
|
| 57 |
+
_SUBMESH_FREQ = "submesh"
|
| 58 |
+
|
| 59 |
# ── In-memory Phase-0 stores (SQLite promotion planned for Phase 1) ──────────
|
| 60 |
_NODES: Dict[str, dict] = {} # origin_slug -> node record
|
| 61 |
_COORDS: Dict[str, dict] = {} # coord -> {origin_slug, op}
|
| 62 |
+
_SESSIONS: Dict[str, dict] = {} # origin_slug -> {cookies, headers, expires, ...}
|
| 63 |
_LOG: List[dict] = [] # rolling event log for /stream
|
| 64 |
_LOG_MAX = 500
|
| 65 |
|
|
|
|
| 69 |
origin: str
|
| 70 |
kind: str = "url" # "url" | "manifest" | "nicoli" (Phase 0.5)
|
| 71 |
cookies: Any = None # list[dict] | dict | JSON str | Netscape text
|
| 72 |
+
headers: Any = None # dict | list[{name,value}] | JSON str | "H: v" lines
|
| 73 |
manifest: Optional[dict] = None
|
| 74 |
scan_openapi: bool = True # try /openapi.json when kind=url
|
| 75 |
|
|
|
|
| 78 |
coord: str
|
| 79 |
input: dict = {}
|
| 80 |
cookies: Any = None # per-call override; else pulls from session store
|
| 81 |
+
headers: Any = None # per-call override; else pulls from session store
|
| 82 |
|
| 83 |
|
| 84 |
class SessionReq(BaseModel):
|
| 85 |
origin: str
|
| 86 |
+
cookies: Any = None
|
| 87 |
+
headers: Any = None
|
| 88 |
ttl_ms: int = 24 * 3600 * 1000 # default 24h
|
| 89 |
|
| 90 |
# ── Helpers ───────────────────────────────────────────────────────────────────
|
|
|
|
| 133 |
return []
|
| 134 |
|
| 135 |
|
| 136 |
+
def _normalize_headers(raw: Any) -> List[dict]:
|
| 137 |
+
"""Accept dict | list[{name,value}] | JSON str | 'H: v' lines. Return normalized list."""
|
| 138 |
+
if raw is None:
|
| 139 |
+
return []
|
| 140 |
+
if isinstance(raw, dict):
|
| 141 |
+
return [{"name": str(k), "value": str(v)} for k, v in raw.items()]
|
| 142 |
+
if isinstance(raw, list):
|
| 143 |
+
out: List[dict] = []
|
| 144 |
+
for h in raw:
|
| 145 |
+
if isinstance(h, dict) and "name" in h and "value" in h:
|
| 146 |
+
out.append({"name": str(h["name"]), "value": str(h["value"])})
|
| 147 |
+
return out
|
| 148 |
+
if isinstance(raw, str):
|
| 149 |
+
s = raw.strip()
|
| 150 |
+
if s.startswith("{") or s.startswith("["):
|
| 151 |
+
try:
|
| 152 |
+
return _normalize_headers(json.loads(s))
|
| 153 |
+
except Exception:
|
| 154 |
+
pass
|
| 155 |
+
out: List[dict] = []
|
| 156 |
+
for line in s.splitlines():
|
| 157 |
+
line = line.strip()
|
| 158 |
+
if not line or line.startswith("#"):
|
| 159 |
+
continue
|
| 160 |
+
if ":" in line:
|
| 161 |
+
name, _, value = line.partition(":")
|
| 162 |
+
name, value = name.strip(), value.strip()
|
| 163 |
+
if name and value:
|
| 164 |
+
out.append({"name": name, "value": value})
|
| 165 |
+
return out
|
| 166 |
+
return []
|
| 167 |
+
|
| 168 |
+
|
| 169 |
+
def _pair_hashes(pairs: List[dict]) -> List[str]:
|
| 170 |
+
"""Provenance only — cookie/header VALUES never leave memory / never logged."""
|
| 171 |
+
return [hashlib.sha256(f"{p.get('name','')}:{p.get('value','')}".encode()).hexdigest()[:8]
|
| 172 |
+
for p in pairs]
|
| 173 |
|
| 174 |
|
| 175 |
async def _fetch_openapi(base: str) -> Optional[dict]:
|
|
|
|
| 241 |
"requires_body": False,
|
| 242 |
}]
|
| 243 |
|
| 244 |
+
|
| 245 |
+
def _broadcast_dual(signal_registry, per_slug_freq: str, source_type: str,
|
| 246 |
+
data: dict, metadata: dict) -> None:
|
| 247 |
+
"""
|
| 248 |
+
Emit each event twice: once on the per-origin frequency (submesh.node.<slug>
|
| 249 |
+
or submesh.call.<slug>) for scoped tuning, once on the shared _SUBMESH_FREQ
|
| 250 |
+
("submesh") for global keyword tune-in. Best-effort; failures swallowed.
|
| 251 |
+
"""
|
| 252 |
+
for freq in (per_slug_freq, _SUBMESH_FREQ):
|
| 253 |
+
try:
|
| 254 |
+
signal_registry.broadcast(
|
| 255 |
+
name=freq, source_type=source_type,
|
| 256 |
+
data=data, metadata=metadata,
|
| 257 |
+
)
|
| 258 |
+
except Exception:
|
| 259 |
+
pass
|
| 260 |
+
|
| 261 |
+
|
| 262 |
+
def _build_session_record(cookies: List[dict], headers: List[dict], ttl_s: float) -> dict:
|
| 263 |
+
return {
|
| 264 |
+
"cookies": cookies,
|
| 265 |
+
"headers": headers,
|
| 266 |
+
"expires": time.time() + ttl_s,
|
| 267 |
+
"cookie_hashes": _pair_hashes(cookies),
|
| 268 |
+
"header_hashes": _pair_hashes(headers),
|
| 269 |
+
}
|
| 270 |
+
|
| 271 |
+
# ── Scan UI (paste-based session sync page) ──────────────────────────────────
|
| 272 |
+
# Neutral chrome / restrained palette — no generic AI gradient look.
|
| 273 |
+
|
| 274 |
+
_SCAN_UI = """<!DOCTYPE html>
|
| 275 |
+
<html lang="en">
|
| 276 |
+
<head>
|
| 277 |
+
<meta charset="UTF-8">
|
| 278 |
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
| 279 |
+
<title>SignalMesh · Submesh Scan</title>
|
| 280 |
+
<style>
|
| 281 |
+
* { margin:0; padding:0; box-sizing:border-box }
|
| 282 |
+
:root {
|
| 283 |
+
--bg:#0c0e11; --panel:#14181d; --panel2:#1a1f26; --panel3:#22282f;
|
| 284 |
+
--line:#242b33; --line2:#2f3742;
|
| 285 |
+
--text:#e7e5df; --muted:#8b929c; --dim:#5b626c;
|
| 286 |
+
--accent:#46d39a; --accent-dim:#2f8865;
|
| 287 |
+
--warn:#e0a848; --error:#d15656;
|
| 288 |
+
--mono:ui-monospace,SFMono-Regular,Menlo,monospace;
|
| 289 |
+
--font:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,Helvetica,Arial,sans-serif;
|
| 290 |
+
}
|
| 291 |
+
html, body { height:100% }
|
| 292 |
+
body {
|
| 293 |
+
background:var(--bg); color:var(--text); font-family:var(--font);
|
| 294 |
+
min-height:100vh; padding:32px 24px;
|
| 295 |
+
}
|
| 296 |
+
.wrap { max-width:820px; margin:0 auto }
|
| 297 |
+
header { border-bottom:1px solid var(--line); padding-bottom:20px; margin-bottom:28px }
|
| 298 |
+
h1 { font-size:1.6rem; font-weight:600; letter-spacing:.2px; margin-bottom:6px }
|
| 299 |
+
h1 .a { color:var(--accent) }
|
| 300 |
+
.sub { color:var(--muted); font-size:.9rem; line-height:1.55; max-width:640px }
|
| 301 |
+
h2 { font-size:1rem; font-weight:600; margin:24px 0 10px; letter-spacing:.3px }
|
| 302 |
+
.card {
|
| 303 |
+
background:var(--panel); border:1px solid var(--line); border-radius:8px;
|
| 304 |
+
padding:20px; margin-bottom:16px;
|
| 305 |
+
}
|
| 306 |
+
label { display:block; font-size:.72rem; color:var(--muted); margin-bottom:6px;
|
| 307 |
+
letter-spacing:.4px; text-transform:uppercase }
|
| 308 |
+
input[type=text], input[type=password], textarea, select {
|
| 309 |
+
width:100%; background:var(--panel2); border:1px solid var(--line2); color:var(--text);
|
| 310 |
+
padding:10px 12px; border-radius:6px; font-family:var(--mono); font-size:.85rem;
|
| 311 |
+
outline:none; transition:border .15s;
|
| 312 |
+
}
|
| 313 |
+
input:focus, textarea:focus, select:focus { border-color:var(--accent) }
|
| 314 |
+
textarea { resize:vertical; min-height:110px; line-height:1.5 }
|
| 315 |
+
.hint { color:var(--dim); font-size:.75rem; margin-top:6px; line-height:1.5 }
|
| 316 |
+
.row { margin-bottom:16px }
|
| 317 |
+
.btn-row { display:flex; gap:10px; align-items:center; margin-top:6px; flex-wrap:wrap }
|
| 318 |
+
button {
|
| 319 |
+
background:var(--accent); color:#0c0e11; border:0; padding:10px 22px; border-radius:6px;
|
| 320 |
+
font-weight:600; font-size:.9rem; cursor:pointer; transition:background .15s;
|
| 321 |
+
font-family:var(--font);
|
| 322 |
+
}
|
| 323 |
+
button:hover:not(:disabled) { background:var(--accent-dim); color:var(--text) }
|
| 324 |
+
button:disabled { opacity:.45; cursor:not-allowed }
|
| 325 |
+
button.ghost { background:transparent; color:var(--muted); border:1px solid var(--line2) }
|
| 326 |
+
button.ghost:hover:not(:disabled) { background:var(--panel2); color:var(--text) }
|
| 327 |
+
#out {
|
| 328 |
+
background:var(--panel3); border:1px solid var(--line2); border-radius:6px;
|
| 329 |
+
padding:14px; font-family:var(--mono); font-size:.78rem; color:var(--muted);
|
| 330 |
+
white-space:pre-wrap; word-break:break-word; max-height:400px; overflow:auto;
|
| 331 |
+
margin-top:16px; display:none;
|
| 332 |
+
}
|
| 333 |
+
#out.ok { border-color:var(--accent-dim); color:var(--text) }
|
| 334 |
+
#out.err { border-color:var(--error); color:var(--warn) }
|
| 335 |
+
.wordmark { font-family:var(--mono); font-size:.72rem; color:var(--dim); letter-spacing:.5px }
|
| 336 |
+
a { color:var(--accent); text-decoration:none }
|
| 337 |
+
a:hover { text-decoration:underline }
|
| 338 |
+
kbd {
|
| 339 |
+
font-family:var(--mono); font-size:.72rem; background:var(--panel3);
|
| 340 |
+
padding:2px 6px; border-radius:3px; border:1px solid var(--line2);
|
| 341 |
+
}
|
| 342 |
+
.meta { color:var(--dim); font-size:.75rem; margin-top:8px; font-family:var(--mono) }
|
| 343 |
+
</style>
|
| 344 |
+
</head>
|
| 345 |
+
<body>
|
| 346 |
+
<div class="wrap">
|
| 347 |
+
|
| 348 |
+
<header>
|
| 349 |
+
<div class="wordmark">SIGNALMESH · SUBMESH</div>
|
| 350 |
+
<h1>Scan <span class="a">→</span> attach your session to an origin</h1>
|
| 351 |
+
<p class="sub">
|
| 352 |
+
Paste cookies (Netscape / JSON) and/or headers (KV pairs or JSON) that your browser
|
| 353 |
+
already carries for a target origin. The submesh stores them scoped to that origin
|
| 354 |
+
and pipes them through on every <kbd>POST /api/submesh/call</kbd> to matching coords.
|
| 355 |
+
Values live in memory only; never logged, only 8-char hashes retained for provenance.
|
| 356 |
+
Same trust boundary as an autofilled <em>Submit Payment</em>: user-triggered, local-side,
|
| 357 |
+
and the credential the target already accepted IS the credential.
|
| 358 |
+
</p>
|
| 359 |
+
</header>
|
| 360 |
+
|
| 361 |
+
<div class="card">
|
| 362 |
+
<h2>1. Auth key</h2>
|
| 363 |
+
<div class="row">
|
| 364 |
+
<label for="key">X-SignalMesh-Key</label>
|
| 365 |
+
<input type="password" id="key" placeholder="smesh-free-demo" value="smesh-free-demo"/>
|
| 366 |
+
<div class="hint">Your submesh API key. Defaults to the free demo tier.</div>
|
| 367 |
+
</div>
|
| 368 |
+
</div>
|
| 369 |
+
|
| 370 |
+
<div class="card">
|
| 371 |
+
<h2>2. Target origin</h2>
|
| 372 |
+
<div class="row">
|
| 373 |
+
<label for="origin">Origin URL</label>
|
| 374 |
+
<input type="text" id="origin" placeholder="https://github.com"/>
|
| 375 |
+
<div class="hint">The service you want the mesh to reach AS YOU. Include scheme.</div>
|
| 376 |
+
</div>
|
| 377 |
+
</div>
|
| 378 |
+
|
| 379 |
+
<div class="card">
|
| 380 |
+
<h2>3. Session material (cookies + / OR headers)</h2>
|
| 381 |
+
<div class="row">
|
| 382 |
+
<label for="cookies">Cookies</label>
|
| 383 |
+
<textarea id="cookies" placeholder='JSON: [{"name":"user_session","value":"..."},{"name":"logged_in","value":"yes"}]
|
| 384 |
+
Or Netscape cookies.txt: .github.com TRUE / TRUE 0 user_session abc123...
|
| 385 |
+
Or object: {"user_session":"abc123","logged_in":"yes"}'></textarea>
|
| 386 |
+
<div class="hint">
|
| 387 |
+
Fastest capture in Chrome/Brave DevTools: <kbd>Application → Storage → Cookies →</kbd>
|
| 388 |
+
your origin, copy the rows you need. Values are never logged.
|
| 389 |
+
</div>
|
| 390 |
+
</div>
|
| 391 |
+
<div class="row">
|
| 392 |
+
<label for="headers">Headers</label>
|
| 393 |
+
<textarea id="headers" placeholder='JSON: {"Authorization":"Bearer ghp_...","X-GitHub-Api-Version":"2022-11-28"}
|
| 394 |
+
Or lines: Authorization: Bearer ghp_...
|
| 395 |
+
X-API-Key: your_key'></textarea>
|
| 396 |
+
<div class="hint">
|
| 397 |
+
For header-based auth (GitHub Bearer, HF X-SignalMesh-Key, etc.). Same in-memory scoping.
|
| 398 |
+
</div>
|
| 399 |
+
</div>
|
| 400 |
+
<div class="row">
|
| 401 |
+
<label for="ttl">TTL (hours)</label>
|
| 402 |
+
<input type="text" id="ttl" placeholder="24" value="24"/>
|
| 403 |
+
<div class="hint">Session drops from memory after this window.</div>
|
| 404 |
+
</div>
|
| 405 |
+
</div>
|
| 406 |
+
|
| 407 |
+
<div class="btn-row">
|
| 408 |
+
<button id="attach">Attach session</button>
|
| 409 |
+
<button id="wrap" class="ghost">Wrap + attach</button>
|
| 410 |
+
<button id="clear" class="ghost">Clear</button>
|
| 411 |
+
</div>
|
| 412 |
+
<div class="meta" id="meta"></div>
|
| 413 |
+
|
| 414 |
+
<pre id="out"></pre>
|
| 415 |
+
|
| 416 |
+
</div>
|
| 417 |
+
|
| 418 |
+
<script>
|
| 419 |
+
const $ = (id) => document.getElementById(id);
|
| 420 |
+
const out = $('out');
|
| 421 |
+
|
| 422 |
+
function show(res, ok) {
|
| 423 |
+
out.style.display = 'block';
|
| 424 |
+
out.className = ok ? 'ok' : 'err';
|
| 425 |
+
try { out.textContent = JSON.stringify(res, null, 2); }
|
| 426 |
+
catch { out.textContent = String(res); }
|
| 427 |
+
}
|
| 428 |
+
|
| 429 |
+
function parseMaybeJSON(s) {
|
| 430 |
+
s = (s || '').trim();
|
| 431 |
+
if (!s) return null;
|
| 432 |
+
if (s.startsWith('{') || s.startsWith('[')) {
|
| 433 |
+
try { return JSON.parse(s); } catch { return s; }
|
| 434 |
+
}
|
| 435 |
+
return s;
|
| 436 |
+
}
|
| 437 |
+
|
| 438 |
+
async function post(path, body) {
|
| 439 |
+
const key = $('key').value.trim() || 'smesh-free-demo';
|
| 440 |
+
const r = await fetch(path, {
|
| 441 |
+
method: 'POST',
|
| 442 |
+
headers: {'Content-Type': 'application/json', 'X-SignalMesh-Key': key},
|
| 443 |
+
body: JSON.stringify(body),
|
| 444 |
+
});
|
| 445 |
+
const data = await r.json().catch(() => ({error: 'non-JSON response', status: r.status}));
|
| 446 |
+
return {status: r.status, body: data};
|
| 447 |
+
}
|
| 448 |
+
|
| 449 |
+
$('attach').addEventListener('click', async () => {
|
| 450 |
+
const origin = $('origin').value.trim();
|
| 451 |
+
if (!origin) { show({error: 'origin required'}, false); return; }
|
| 452 |
+
const ttl_ms = (parseFloat($('ttl').value) || 24) * 3600 * 1000;
|
| 453 |
+
const cookies = parseMaybeJSON($('cookies').value);
|
| 454 |
+
const headers = parseMaybeJSON($('headers').value);
|
| 455 |
+
if (!cookies && !headers) { show({error: 'paste cookies or headers'}, false); return; }
|
| 456 |
+
const {status, body} = await post('/api/submesh/session', {origin, cookies, headers, ttl_ms});
|
| 457 |
+
show(body, status < 400);
|
| 458 |
+
});
|
| 459 |
+
|
| 460 |
+
$('wrap').addEventListener('click', async () => {
|
| 461 |
+
const origin = $('origin').value.trim();
|
| 462 |
+
if (!origin) { show({error: 'origin required'}, false); return; }
|
| 463 |
+
const cookies = parseMaybeJSON($('cookies').value);
|
| 464 |
+
const headers = parseMaybeJSON($('headers').value);
|
| 465 |
+
const {status, body} = await post('/api/submesh/wrap', {origin, kind: 'url', cookies, headers});
|
| 466 |
+
show(body, status < 400);
|
| 467 |
+
});
|
| 468 |
+
|
| 469 |
+
$('clear').addEventListener('click', () => {
|
| 470 |
+
$('cookies').value = ''; $('headers').value = '';
|
| 471 |
+
out.style.display = 'none';
|
| 472 |
+
});
|
| 473 |
+
|
| 474 |
+
(async () => {
|
| 475 |
+
try {
|
| 476 |
+
const r = await fetch('/api/submesh/nodes',
|
| 477 |
+
{headers: {'X-SignalMesh-Key': 'smesh-free-demo'}});
|
| 478 |
+
if (r.ok) {
|
| 479 |
+
const d = await r.json();
|
| 480 |
+
$('meta').textContent = `mesh has ${d.count || 0} wrapped node(s) right now`;
|
| 481 |
+
}
|
| 482 |
+
} catch {}
|
| 483 |
+
})();
|
| 484 |
+
</script>
|
| 485 |
+
|
| 486 |
+
</body>
|
| 487 |
+
</html>
|
| 488 |
+
"""
|
| 489 |
+
|
| 490 |
# ── Router factory ────────────────────────────────────────────────────────────
|
| 491 |
|
| 492 |
def make_router(*, check_key, signal_registry, sec_omega) -> APIRouter:
|
|
|
|
| 508 |
"events": len(_LOG),
|
| 509 |
}
|
| 510 |
|
| 511 |
+
@router.get("/ui", response_class=HTMLResponse, include_in_schema=False)
|
| 512 |
+
def scan_ui():
|
| 513 |
+
"""Paste-based Scan UI. Public HTML; POSTs from JS supply the key."""
|
| 514 |
+
return HTMLResponse(_SCAN_UI)
|
| 515 |
+
|
| 516 |
@router.post("/wrap", summary="Wrap an origin into coordinate-addressable tools")
|
| 517 |
async def wrap(req: WrapReq, x_signalmesh_key: Optional[str] = Header(default=None)):
|
| 518 |
check_key(x_signalmesh_key)
|
|
|
|
| 547 |
ops = _default_url_ops(slug, base)
|
| 548 |
source = "default"
|
| 549 |
|
| 550 |
+
# Wrap-time session material (optional; can also be sent later via /session)
|
| 551 |
cookies = _normalize_cookies(req.cookies)
|
| 552 |
+
headers = _normalize_headers(req.headers)
|
| 553 |
+
if cookies or headers:
|
| 554 |
+
_SESSIONS[slug] = _build_session_record(cookies, headers, 86400.0)
|
|
|
|
|
|
|
|
|
|
| 555 |
|
| 556 |
_NODES[slug] = {
|
| 557 |
"origin": origin,
|
| 558 |
"base": base,
|
| 559 |
"slug": slug,
|
| 560 |
"ops": ops,
|
| 561 |
+
"op_count": len(ops),
|
| 562 |
"source": source,
|
| 563 |
+
"authed": bool(cookies or headers),
|
| 564 |
"wrapped_at": time.time(),
|
| 565 |
}
|
| 566 |
for op in ops:
|
| 567 |
_COORDS[op["coord"]] = {"origin_slug": slug, "op": op}
|
| 568 |
|
| 569 |
+
# Broadcast node registration: per-slug + shared "submesh"
|
| 570 |
+
_broadcast_dual(
|
| 571 |
+
signal_registry,
|
| 572 |
+
per_slug_freq=f"submesh.node.{slug}",
|
| 573 |
+
source_type="submesh_wrap",
|
| 574 |
+
data={
|
| 575 |
+
"origin": origin,
|
| 576 |
+
"coords": [o["coord"] for o in ops],
|
| 577 |
+
"authed": bool(cookies or headers),
|
| 578 |
+
"op_count": len(ops),
|
| 579 |
+
"source": source,
|
| 580 |
+
},
|
| 581 |
+
metadata={"source": source, "op_count": len(ops), "slug": slug},
|
| 582 |
+
)
|
| 583 |
|
| 584 |
_log({"event": "wrap", "slug": slug, "source": source,
|
| 585 |
+
"op_count": len(ops), "authed": bool(cookies or headers)})
|
| 586 |
|
| 587 |
return {
|
| 588 |
"wrapped": True,
|
| 589 |
"origin": origin,
|
| 590 |
"slug": slug,
|
| 591 |
"source": source,
|
| 592 |
+
"authed": bool(cookies or headers),
|
| 593 |
+
"cookie_hashes": _pair_hashes(cookies) if cookies else [],
|
| 594 |
+
"header_hashes": _pair_hashes(headers) if headers else [],
|
| 595 |
"coords": [o["coord"] for o in ops],
|
| 596 |
"op_count": len(ops),
|
| 597 |
}
|
|
|
|
| 601 |
check_key(x_signalmesh_key)
|
| 602 |
return {"nodes": list(_NODES.values()), "count": len(_NODES)}
|
| 603 |
|
| 604 |
+
@router.post("/session",
|
| 605 |
+
summary="Attach browser-session cookies AND/OR auth headers to an origin")
|
| 606 |
def session(req: SessionReq, x_signalmesh_key: Optional[str] = Header(default=None)):
|
| 607 |
check_key(x_signalmesh_key)
|
| 608 |
cookies = _normalize_cookies(req.cookies)
|
| 609 |
+
headers = _normalize_headers(req.headers)
|
| 610 |
+
if not cookies and not headers:
|
| 611 |
+
raise HTTPException(400, "provide at least one of cookies or headers")
|
| 612 |
slug = _slug(req.origin)
|
| 613 |
+
_SESSIONS[slug] = _build_session_record(cookies, headers, req.ttl_ms / 1000.0)
|
|
|
|
|
|
|
|
|
|
|
|
|
| 614 |
if slug in _NODES:
|
| 615 |
_NODES[slug]["authed"] = True
|
| 616 |
+
_log({"event": "session_attached", "slug": slug,
|
| 617 |
+
"cookie_count": len(cookies), "header_count": len(headers)})
|
| 618 |
return {
|
| 619 |
"attached": True,
|
| 620 |
"slug": slug,
|
| 621 |
"cookie_count": len(cookies),
|
| 622 |
+
"header_count": len(headers),
|
| 623 |
+
"cookie_hashes": _pair_hashes(cookies),
|
| 624 |
+
"header_hashes": _pair_hashes(headers),
|
| 625 |
+
"expires_in_s": req.ttl_ms / 1000.0,
|
| 626 |
}
|
| 627 |
|
| 628 |
+
@router.post("/call", summary="Invoke a submesh coord with cookie + header carry")
|
| 629 |
async def call(req: CallReq, x_signalmesh_key: Optional[str] = Header(default=None)):
|
| 630 |
check_key(x_signalmesh_key)
|
| 631 |
entry = _COORDS.get(req.coord)
|
|
|
|
| 647 |
if not safe:
|
| 648 |
raise HTTPException(400, f"SEC-Ω blocked: {reason}")
|
| 649 |
|
| 650 |
+
# Resolve cookies + headers: per-call override > stored session > none
|
| 651 |
cookies = _normalize_cookies(req.cookies) if req.cookies else []
|
| 652 |
+
headers = _normalize_headers(req.headers) if req.headers else []
|
| 653 |
+
sess = _SESSIONS.get(slug)
|
| 654 |
+
if sess and sess["expires"] > time.time():
|
| 655 |
+
if not cookies:
|
| 656 |
+
cookies = sess.get("cookies", [])
|
| 657 |
+
if not headers:
|
| 658 |
+
headers = sess.get("headers", [])
|
| 659 |
|
| 660 |
method = op["method"]
|
| 661 |
url = op["url"]
|
|
|
|
| 679 |
except Exception:
|
| 680 |
continue
|
| 681 |
|
| 682 |
+
hdrs = {"User-Agent": "SignalMesh-Submesh/0"}
|
| 683 |
+
for h in headers:
|
| 684 |
+
try:
|
| 685 |
+
hdrs[str(h["name"])] = str(h["value"])
|
| 686 |
+
except Exception:
|
| 687 |
+
continue
|
| 688 |
+
|
| 689 |
started = time.time()
|
| 690 |
try:
|
| 691 |
+
async with httpx.AsyncClient(follow_redirects=True, timeout=20.0,
|
| 692 |
+
cookies=jar, headers=hdrs) as client:
|
| 693 |
r = await client.request(method, url, params=params, json=body)
|
| 694 |
ct = r.headers.get("content-type", "")
|
| 695 |
if "application/json" in ct:
|
|
|
|
| 700 |
"coord": req.coord,
|
| 701 |
"origin_slug": slug,
|
| 702 |
"status": r.status_code,
|
| 703 |
+
"authed": bool(cookies or headers),
|
| 704 |
+
"cookie_hashes": _pair_hashes(cookies),
|
| 705 |
+
"header_hashes": _pair_hashes(headers),
|
| 706 |
"payload_type": payload_type,
|
| 707 |
"payload": payload,
|
| 708 |
"elapsed_ms": int((time.time() - started) * 1000),
|
|
|
|
| 714 |
"origin_slug": slug,
|
| 715 |
"status": 0,
|
| 716 |
"error": str(e),
|
| 717 |
+
"authed": bool(cookies or headers),
|
| 718 |
"elapsed_ms": int((time.time() - started) * 1000),
|
| 719 |
}
|
| 720 |
|
| 721 |
+
# Broadcast result: per-slug + shared "submesh" frequency
|
| 722 |
+
_broadcast_dual(
|
| 723 |
+
signal_registry,
|
| 724 |
+
per_slug_freq=f"submesh.call.{slug}",
|
| 725 |
+
source_type="submesh_call",
|
| 726 |
+
data=envelope,
|
| 727 |
+
metadata={"coord": req.coord, "status": envelope.get("status"),
|
| 728 |
+
"authed": bool(cookies or headers), "slug": slug},
|
| 729 |
+
)
|
|
|
|
| 730 |
|
| 731 |
_log({"event": "call", "coord": req.coord,
|
| 732 |
+
"status": envelope.get("status"),
|
| 733 |
+
"authed": bool(cookies or headers)})
|
| 734 |
|
| 735 |
return envelope
|
| 736 |
|