Spaces:
Restarting
chore(sync): mirror backend .py + Dockerfile to Space (hf-sync-backend)
Browse filesAutomated backend sync from szl-holdings/a11oy main via hf-sync-backend.
Updated (differed from the Space): Dockerfile, szl_connector_mcp.py, szl_connectors/__init__.py, szl_connectors/base.py, szl_connectors/bindings.py, szl_connectors/comms/__init__.py, szl_connectors/comms/comms_connectors.py, szl_connectors/crm/__init__.py, szl_connectors/crm/crm_connectors.py, szl_connectors/data_sources/__init__.py, szl_connectors/data_sources/geo.py, szl_connectors/data_sources/macro.py, szl_connectors/data_sources/maritime_air.py, szl_connectors/data_sources/research.py, szl_connectors/data_sources/security.py, szl_connectors/erp/__init__.py, szl_connectors/erp/erpnext.py, szl_connectors/erp/odoo.py, szl_connectors/erp/others.py, szl_connectors/erp/sap_s4.py, szl_connectors/governance.py, szl_connectors/identity/__init__.py, szl_connectors/identity/identity_connectors.py, szl_connectors/itsm/__init__.py, szl_connectors/itsm/itsm_connectors.py, szl_connectors/oauth.py, szl_connectors/observability/__init__.py, szl_connectors/observability/observability_connectors.py, szl_connectors/ready.py, szl_connectors/registry.py, szl_connectors/storage/__init__.py, szl_connectors/storage/storage_connectors.py, szl_connectors/warehouse/__init__.py, szl_connectors/warehouse/warehouse_connectors.py, szl_connectors_serve.py
Keeps the Space-built backend (serve.py + the Dockerfile-COPY'd .py
modules) identical to GitHub main so the Space never rebuilds from a
stale backend and new endpoints don't 404 there.
- Dockerfile +17 -0
- szl_connector_mcp.py +118 -0
- szl_connectors/__init__.py +56 -0
- szl_connectors/base.py +304 -0
- szl_connectors/bindings.py +41 -0
- szl_connectors/comms/__init__.py +3 -0
- szl_connectors/comms/comms_connectors.py +83 -0
- szl_connectors/crm/__init__.py +3 -0
- szl_connectors/crm/crm_connectors.py +254 -0
- szl_connectors/data_sources/__init__.py +3 -0
- szl_connectors/data_sources/geo.py +148 -0
- szl_connectors/data_sources/macro.py +116 -0
- szl_connectors/data_sources/maritime_air.py +128 -0
- szl_connectors/data_sources/research.py +164 -0
- szl_connectors/data_sources/security.py +267 -0
- szl_connectors/erp/__init__.py +3 -0
- szl_connectors/erp/erpnext.py +115 -0
- szl_connectors/erp/odoo.py +136 -0
- szl_connectors/erp/others.py +188 -0
- szl_connectors/erp/sap_s4.py +77 -0
- szl_connectors/governance.py +169 -0
- szl_connectors/identity/__init__.py +3 -0
- szl_connectors/identity/identity_connectors.py +108 -0
- szl_connectors/itsm/__init__.py +3 -0
- szl_connectors/itsm/itsm_connectors.py +163 -0
- szl_connectors/oauth.py +240 -0
- szl_connectors/observability/__init__.py +3 -0
- szl_connectors/observability/observability_connectors.py +129 -0
- szl_connectors/ready.py +152 -0
- szl_connectors/registry.py +99 -0
- szl_connectors/storage/__init__.py +3 -0
- szl_connectors/storage/storage_connectors.py +179 -0
- szl_connectors/warehouse/__init__.py +3 -0
- szl_connectors/warehouse/warehouse_connectors.py +182 -0
- szl_connectors_serve.py +146 -0
|
@@ -650,6 +650,23 @@ COPY szl_bounties.py ./szl_bounties.py
|
|
| 650 |
COPY szl_putnam.py ./szl_putnam.py
|
| 651 |
COPY bounties/ ./bounties/
|
| 652 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 653 |
CMD ["python", "serve.py"]
|
| 654 |
|
| 655 |
|
|
|
|
| 650 |
COPY szl_putnam.py ./szl_putnam.py
|
| 651 |
COPY bounties/ ./bounties/
|
| 652 |
|
| 653 |
+
# ---------------------------------------------------------------------------
|
| 654 |
+
# SZL Enterprise Connector Framework. serve.py imports szl_connectors_serve
|
| 655 |
+
# (which imports the szl_connectors/ package + szl_connector_mcp) and calls
|
| 656 |
+
# register(app, "a11oy") to mount /api/a11oy/connectors + per-connector
|
| 657 |
+
# health/read/write/oauth + the /integrations page. Per-file/dir COPY (this
|
| 658 |
+
# Dockerfile never uses `COPY . .`) -- without these the import fails and the
|
| 659 |
+
# connector routes fall through to the SPA. pages/integrations.html is already
|
| 660 |
+
# shipped by the wholesale `COPY pages/ ./pages/` above, so no extra page COPY
|
| 661 |
+
# is needed. 52 connectors (13 live-now / 38 credential-READY / 1 SAMPLE).
|
| 662 |
+
# Doctrine v11: honest states, no fabricated records, Lambda-gated+DSSE writes.
|
| 663 |
+
# Signed-off-by: Stephen P. Lutar Jr. <stephenlutar2@gmail.com>
|
| 664 |
+
# Co-Authored-By: Perplexity Computer Agent <agent@perplexity.ai>
|
| 665 |
+
# ---------------------------------------------------------------------------
|
| 666 |
+
COPY szl_connectors/ ./szl_connectors/
|
| 667 |
+
COPY szl_connectors_serve.py ./szl_connectors_serve.py
|
| 668 |
+
COPY szl_connector_mcp.py ./szl_connector_mcp.py
|
| 669 |
+
|
| 670 |
CMD ["python", "serve.py"]
|
| 671 |
|
| 672 |
|
|
@@ -0,0 +1,118 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# SPDX-License-Identifier: Apache-2.0
|
| 2 |
+
# © 2026 Lutar, Stephen P. — SZL Holdings · ORCID 0009-0001-0110-4173 · Doctrine v11
|
| 3 |
+
# Authored by SZL Enterprise-Integration Team. Co-Authored-By: Perplexity Computer Agent.
|
| 4 |
+
"""szl_connector_mcp — MCP tool surface for the Enterprise Connector framework.
|
| 5 |
+
|
| 6 |
+
Exposes THREE governed tools to Chaski / the a11oy agent loop:
|
| 7 |
+
|
| 8 |
+
szl_connector_health(connector_id) → honest health (CONNECTED/READY/SAMPLE/ERROR)
|
| 9 |
+
szl_connector_read(connector_id, limit, q) → live records | READY (no creds) | SAMPLE
|
| 10 |
+
szl_connector_write(connector_id, action) → Λ-gated + DSSE-receipted write (state-changing)
|
| 11 |
+
|
| 12 |
+
These are ADDITIVE. `TOOL_SCHEMAS` is the OpenAI-style function-tool list to be
|
| 13 |
+
merged into a11oy_code_orchestrator.TOOL_SCHEMAS; `dispatch(name, args)` is the
|
| 14 |
+
handler to be called from `_dispatch_tool`. `STATE_CHANGING` names the write tool
|
| 15 |
+
so the orchestrator's reversibility gate engages (2-person/quorum).
|
| 16 |
+
|
| 17 |
+
DOCTRINE: never fabricates a record; write() is Λ-gated (Λ≠1.0) + DSSE-receipted;
|
| 18 |
+
receipts carry credential FINGERPRINT hashes only (never the key value).
|
| 19 |
+
"""
|
| 20 |
+
from __future__ import annotations
|
| 21 |
+
|
| 22 |
+
from typing import Any
|
| 23 |
+
|
| 24 |
+
import szl_connectors as sc
|
| 25 |
+
|
| 26 |
+
# ── OpenAI-style function-tool schemas (merge into orchestrator TOOL_SCHEMAS) ──
|
| 27 |
+
TOOL_SCHEMAS: list[dict[str, Any]] = [
|
| 28 |
+
{"type": "function", "function": {
|
| 29 |
+
"name": "szl_connector_health",
|
| 30 |
+
"description": ("Honest health of an enterprise connector (CRM/ERP/identity/"
|
| 31 |
+
"comms/ITSM/warehouse/storage/observability/data-source). Returns "
|
| 32 |
+
"state CONNECTED (live)/READY (set the named secrets)/SAMPLE (no "
|
| 33 |
+
"free tier)/ERROR — plus the exact env-var secret names. NEVER fabricates."),
|
| 34 |
+
"parameters": {"type": "object", "properties": {
|
| 35 |
+
"connector_id": {"type": "string", "description": "e.g. salesforce, nvd_cve, odoo, okta"}},
|
| 36 |
+
"required": ["connector_id"]}}},
|
| 37 |
+
{"type": "function", "function": {
|
| 38 |
+
"name": "szl_connector_read",
|
| 39 |
+
"description": ("Read records from an enterprise connector. CONNECTED → live "
|
| 40 |
+
"provider records; READY → [] + 'provide credentials' + exact secret "
|
| 41 |
+
"names; SAMPLE → labelled fixture (no free tier). NEVER fabricates a record."),
|
| 42 |
+
"parameters": {"type": "object", "properties": {
|
| 43 |
+
"connector_id": {"type": "string"},
|
| 44 |
+
"limit": {"type": "integer", "default": 12},
|
| 45 |
+
"q": {"type": "string", "description": "optional query/keyword"}},
|
| 46 |
+
"required": ["connector_id"]}}},
|
| 47 |
+
{"type": "function", "function": {
|
| 48 |
+
"name": "szl_connector_write",
|
| 49 |
+
"description": ("Write to an enterprise connector (create/update). STATE-CHANGING: "
|
| 50 |
+
"Λ-gated (Λ never 1.0) + DSSE/Khipu-receipted; requires quorum for "
|
| 51 |
+
"state-changing actions. Blocked (with an honest receipt) when no "
|
| 52 |
+
"credentials are present or the gate denies."),
|
| 53 |
+
"parameters": {"type": "object", "properties": {
|
| 54 |
+
"connector_id": {"type": "string"},
|
| 55 |
+
"action": {"type": "object", "description":
|
| 56 |
+
"{method:create|update, object, values:{...}, quorum_present:[...]}"}},
|
| 57 |
+
"required": ["connector_id", "action"]}}},
|
| 58 |
+
]
|
| 59 |
+
|
| 60 |
+
# the orchestrator's reversibility gate should treat write as state-changing
|
| 61 |
+
STATE_CHANGING = {"szl_connector_write"}
|
| 62 |
+
|
| 63 |
+
TOOL_NAMES = {"szl_connector_health", "szl_connector_read", "szl_connector_write"}
|
| 64 |
+
|
| 65 |
+
|
| 66 |
+
def dispatch(name: str, args: dict[str, Any]) -> Any:
|
| 67 |
+
"""Handler for the three connector tools. Returns JSON-serialisable dicts.
|
| 68 |
+
|
| 69 |
+
Call this from a11oy_code_orchestrator._dispatch_tool:
|
| 70 |
+
if name in szl_connector_mcp.TOOL_NAMES:
|
| 71 |
+
return szl_connector_mcp.dispatch(name, args)
|
| 72 |
+
"""
|
| 73 |
+
cid = args.get("connector_id", "")
|
| 74 |
+
if name == "szl_connector_health":
|
| 75 |
+
return sc.health(cid)
|
| 76 |
+
|
| 77 |
+
if name == "szl_connector_read":
|
| 78 |
+
c = sc.get(cid)
|
| 79 |
+
if not c:
|
| 80 |
+
return {"error": f"unknown connector '{cid}'", "known": sc.all_ids()}
|
| 81 |
+
query = {"limit": max(1, min(int(args.get("limit", 12)), 50))}
|
| 82 |
+
if args.get("q"):
|
| 83 |
+
query["q"] = args["q"]
|
| 84 |
+
try:
|
| 85 |
+
return c.read(query).to_dict()
|
| 86 |
+
except Exception as e:
|
| 87 |
+
return {"connector_id": cid, "state": "error", "records": [], "live": False,
|
| 88 |
+
"note": f"read failed: {type(e).__name__}: {e}"}
|
| 89 |
+
|
| 90 |
+
if name == "szl_connector_write":
|
| 91 |
+
c = sc.get(cid)
|
| 92 |
+
if not c:
|
| 93 |
+
return {"error": f"unknown connector '{cid}'", "known": sc.all_ids()}
|
| 94 |
+
if not getattr(c, "writable", False):
|
| 95 |
+
return {"connector_id": cid, "ok": False, "detail": "connector is read-only"}
|
| 96 |
+
try:
|
| 97 |
+
return c.write(args.get("action") or {}).to_dict()
|
| 98 |
+
except Exception as e:
|
| 99 |
+
return {"connector_id": cid, "ok": False, "state": "error",
|
| 100 |
+
"detail": f"write failed: {type(e).__name__}: {e}"}
|
| 101 |
+
|
| 102 |
+
raise ValueError(f"unknown connector tool {name}")
|
| 103 |
+
|
| 104 |
+
|
| 105 |
+
def writable_connectors() -> list[str]:
|
| 106 |
+
"""The connector ids that expose a Λ-gated write()."""
|
| 107 |
+
out = []
|
| 108 |
+
for cid in sc.all_ids():
|
| 109 |
+
cls = sc.REGISTRY.get(cid)
|
| 110 |
+
if cls and getattr(cls, "writable", False):
|
| 111 |
+
out.append(cid)
|
| 112 |
+
return out
|
| 113 |
+
|
| 114 |
+
|
| 115 |
+
__all__ = ["TOOL_SCHEMAS", "STATE_CHANGING", "TOOL_NAMES", "dispatch", "writable_connectors"]
|
| 116 |
+
|
| 117 |
+
# Doctrine v11 LOCKED — 749/14/163 — Λ = Conjecture 1 · honest states · no fabricated records ·
|
| 118 |
+
# write Λ-gated + DSSE-receipted · credential fingerprints only.
|
|
@@ -0,0 +1,56 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# SPDX-License-Identifier: Apache-2.0
|
| 2 |
+
# © 2026 Lutar, Stephen P. — SZL Holdings · ORCID 0009-0001-0110-4173 · Doctrine v11
|
| 3 |
+
# Authored by SZL Enterprise-Integration Team. Co-Authored-By: Perplexity Computer Agent.
|
| 4 |
+
"""szl_connectors — SZL's sovereign Enterprise Connector Framework.
|
| 5 |
+
|
| 6 |
+
ONE honest Connector abstraction (base.py) → every CRM / ERP / identity / comms /
|
| 7 |
+
ITSM / warehouse / storage / observability / data-source provider is a thin
|
| 8 |
+
subclass. Importing this package registers every connector into the registry.
|
| 9 |
+
|
| 10 |
+
DOCTRINE (woven throughout):
|
| 11 |
+
• No fabricated records — honest CONNECTED / READY / SAMPLE / ERROR only.
|
| 12 |
+
• No committed keys — env/Space-secret only; receipts carry fingerprint hashes.
|
| 13 |
+
• Every write() is Λ-gated (Λ never 1.0) + DSSE/Khipu-receipted.
|
| 14 |
+
• Trust never 100%. 0 runtime CDN. GitHub ↔ HF byte-identical.
|
| 15 |
+
|
| 16 |
+
LIVE NOW (free / no-signup, keyless): CISA KEV, NVD CVE, EPSS, MITRE ATT&CK,
|
| 17 |
+
SEC EDGAR, GitHub public, arXiv, HF Hub, Wikidata, USGS, NOAA, Overpass/OSM,
|
| 18 |
+
OpenSky (air). Free ERPs Odoo + ERPNext wired live (XML-RPC / REST).
|
| 19 |
+
|
| 20 |
+
CREDENTIAL-READY (genuine clients, awaiting customer secrets): Salesforce,
|
| 21 |
+
HubSpot, Dynamics 365, Zoho, Pipedrive, Close, Freshsales, SugarCRM, NetSuite,
|
| 22 |
+
Dynamics F&O/BC, Sage, Acumatica, Infor, SAP S/4HANA, Okta, Entra, Auth0,
|
| 23 |
+
Slack, Teams, Jira, ServiceNow, Zendesk, Linear, Snowflake, Databricks,
|
| 24 |
+
BigQuery, Postgres, S3, GCS, Azure Blob, Datadog, Splunk, Grafana.
|
| 25 |
+
|
| 26 |
+
SAMPLE (no free tier; clearly labelled): historical AIS positions.
|
| 27 |
+
"""
|
| 28 |
+
from __future__ import annotations
|
| 29 |
+
|
| 30 |
+
# importing each submodule runs its @register decorators
|
| 31 |
+
from . import data_sources # noqa: F401 (security, macro, research, maritime_air, geo)
|
| 32 |
+
from . import crm # noqa: F401
|
| 33 |
+
from . import erp # noqa: F401
|
| 34 |
+
from . import identity # noqa: F401
|
| 35 |
+
from . import comms # noqa: F401
|
| 36 |
+
from . import itsm # noqa: F401
|
| 37 |
+
from . import warehouse # noqa: F401
|
| 38 |
+
from . import storage # noqa: F401
|
| 39 |
+
from . import observability # noqa: F401
|
| 40 |
+
|
| 41 |
+
from .base import (AuthKind, State, HealthReport, Records, WriteResult, # noqa: F401
|
| 42 |
+
Connector, resolve_state, http_json, http_text, cred_fingerprint)
|
| 43 |
+
from .registry import REGISTRY, register, get, all_ids, manifest, health # noqa: F401
|
| 44 |
+
from .governance import gate_write, lambda_score, quorum_status # noqa: F401
|
| 45 |
+
from . import oauth # noqa: F401
|
| 46 |
+
from . import bindings # noqa: F401
|
| 47 |
+
|
| 48 |
+
__version__ = "1.0.0"
|
| 49 |
+
|
| 50 |
+
__all__ = [
|
| 51 |
+
"REGISTRY", "register", "get", "all_ids", "manifest", "health",
|
| 52 |
+
"Connector", "State", "HealthReport", "Records", "WriteResult",
|
| 53 |
+
"resolve_state", "http_json", "http_text", "cred_fingerprint",
|
| 54 |
+
"gate_write", "lambda_score", "quorum_status", "oauth", "bindings",
|
| 55 |
+
"__version__",
|
| 56 |
+
]
|
|
@@ -0,0 +1,304 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# SPDX-License-Identifier: Apache-2.0
|
| 2 |
+
# © 2026 Lutar, Stephen P. — SZL Holdings · ORCID 0009-0001-0110-4173 · Doctrine v11
|
| 3 |
+
# Authored by SZL Enterprise-Integration Team. Co-Authored-By: Perplexity Computer Agent.
|
| 4 |
+
"""szl_connectors.base — the ONE connector abstraction (not N hacks).
|
| 5 |
+
|
| 6 |
+
Every enterprise CRM/ERP/platform/data-source binds to a single `Connector`
|
| 7 |
+
base class. Each provider is a thin subclass whose ONLY per-provider variation
|
| 8 |
+
is the body of `health()` / `read()` / `write()` (the real HTTP) + the auth
|
| 9 |
+
wiring. The UI, the MCP layer, and the governance layer treat every connector
|
| 10 |
+
identically.
|
| 11 |
+
|
| 12 |
+
HARD DOCTRINE (non-negotiable — woven throughout):
|
| 13 |
+
• NO fabricated records. A connector returns REAL live provider data
|
| 14 |
+
(CONNECTED), an honest empty READY ("provide credentials to activate" +
|
| 15 |
+
the exact secret name), or a clearly-LABELED SAMPLE (`sample_reason` set,
|
| 16 |
+
used ONLY where no free tier exists). ERROR is honest.
|
| 17 |
+
• NO committed keys. Every credential is read from env/Space-secret ONLY.
|
| 18 |
+
Receipts store credential FINGERPRINT HASHES, never the key value.
|
| 19 |
+
• Every write() is Λ-gated (Λ never 1.0) + DSSE/Khipu-receipted.
|
| 20 |
+
• Trust never 100% (conformal anti-overconfidence floor 1/(n+1)).
|
| 21 |
+
• 0 runtime CDN in served apps; these are live DATA fetches (data, not code).
|
| 22 |
+
|
| 23 |
+
This generalizes the proven honest-state discipline already shipping in
|
| 24 |
+
`szl_a11oy_live_feeds.py` (CISA KEV / NVD / arXiv wired live, labelled
|
| 25 |
+
`live: true/false` + `source_status`) into a first-class connector framework.
|
| 26 |
+
|
| 27 |
+
API references targeted (publicly documented shapes; SZL writes its own
|
| 28 |
+
original client code, never copies proprietary SDK code) — see each subclass
|
| 29 |
+
module's docstring + the package NOTICE.
|
| 30 |
+
"""
|
| 31 |
+
from __future__ import annotations
|
| 32 |
+
|
| 33 |
+
import hashlib
|
| 34 |
+
import json
|
| 35 |
+
import os
|
| 36 |
+
import time
|
| 37 |
+
import urllib.error as _ue
|
| 38 |
+
import urllib.parse as _up
|
| 39 |
+
import urllib.request as _ur
|
| 40 |
+
from dataclasses import asdict, dataclass, field
|
| 41 |
+
from datetime import datetime, timezone
|
| 42 |
+
from enum import Enum
|
| 43 |
+
from typing import Any, Literal
|
| 44 |
+
|
| 45 |
+
# ── auth kinds the framework understands ──────────────────────────────────
|
| 46 |
+
AuthKind = Literal["none", "api_key", "oauth2", "basic", "aws_sigv4", "token"]
|
| 47 |
+
|
| 48 |
+
_UA = "SZL-Connectors/1.0 (sovereign enterprise mesh; contact@szlholdings.ai)"
|
| 49 |
+
_TIMEOUT = 8.0
|
| 50 |
+
|
| 51 |
+
|
| 52 |
+
class State(str, Enum):
|
| 53 |
+
"""The honest state of a connector RIGHT NOW."""
|
| 54 |
+
CONNECTED = "connected" # creds present + health() passed → LIVE provider data
|
| 55 |
+
READY = "ready" # implemented + tested vs API shape; awaiting customer creds
|
| 56 |
+
SAMPLE = "sample" # labeled fixture ONLY where no free tier exists
|
| 57 |
+
ERROR = "error" # creds present but provider unreachable/denied (honest)
|
| 58 |
+
|
| 59 |
+
|
| 60 |
+
def _now() -> str:
|
| 61 |
+
return datetime.now(timezone.utc).isoformat()
|
| 62 |
+
|
| 63 |
+
|
| 64 |
+
# ── typed return objects (propagated unchanged to UI + MCP) ────────────────
|
| 65 |
+
@dataclass
|
| 66 |
+
class HealthReport:
|
| 67 |
+
connector_id: str
|
| 68 |
+
state: State
|
| 69 |
+
auth_kind: AuthKind
|
| 70 |
+
env_vars: list[str] # exact secret names this connector reads
|
| 71 |
+
missing_env: list[str] # which are absent (drives "provide credentials")
|
| 72 |
+
provider_base: str # the real API base it will hit
|
| 73 |
+
checked_at: str = field(default_factory=_now)
|
| 74 |
+
detail: str = "" # human-readable honest reason
|
| 75 |
+
latency_ms: float | None = None
|
| 76 |
+
sample_reason: str | None = None # if SAMPLE: WHY (e.g. "no free tier")
|
| 77 |
+
free_tier: bool = False
|
| 78 |
+
|
| 79 |
+
def to_dict(self) -> dict[str, Any]:
|
| 80 |
+
d = asdict(self)
|
| 81 |
+
d["state"] = self.state.value if isinstance(self.state, State) else self.state
|
| 82 |
+
return d
|
| 83 |
+
|
| 84 |
+
|
| 85 |
+
@dataclass
|
| 86 |
+
class Records:
|
| 87 |
+
connector_id: str
|
| 88 |
+
category: str
|
| 89 |
+
state: State # CONNECTED | READY | SAMPLE — propagated to UI
|
| 90 |
+
records: list[dict[str, Any]] # [] when READY (no creds) — NEVER faked
|
| 91 |
+
source: str # provider name + endpoint actually hit
|
| 92 |
+
fetched_at: str = field(default_factory=_now)
|
| 93 |
+
live: bool = False # True only when CONNECTED + real fetch
|
| 94 |
+
receipt_hash: str | None = None # set for write(); reads are low-gate
|
| 95 |
+
note: str = "" # honest label rendered in the UI source-chip
|
| 96 |
+
schema_preview: list[str] = field(default_factory=list) # column headers for READY tiles
|
| 97 |
+
count: int = 0
|
| 98 |
+
|
| 99 |
+
def to_dict(self) -> dict[str, Any]:
|
| 100 |
+
d = asdict(self)
|
| 101 |
+
d["state"] = self.state.value if isinstance(self.state, State) else self.state
|
| 102 |
+
d["count"] = len(self.records)
|
| 103 |
+
return d
|
| 104 |
+
|
| 105 |
+
|
| 106 |
+
@dataclass
|
| 107 |
+
class WriteResult:
|
| 108 |
+
connector_id: str
|
| 109 |
+
ok: bool
|
| 110 |
+
state: State
|
| 111 |
+
receipt_hash: str | None # DSSE/Khipu receipt — REQUIRED for any write
|
| 112 |
+
lambda_value: float | None # Λ score of the action (NEVER 1.0)
|
| 113 |
+
quorum: dict | None = None # 2-person / 3-of-4 status for state-changing
|
| 114 |
+
detail: str = ""
|
| 115 |
+
dsse: dict | None = None
|
| 116 |
+
|
| 117 |
+
def to_dict(self) -> dict[str, Any]:
|
| 118 |
+
d = asdict(self)
|
| 119 |
+
d["state"] = self.state.value if isinstance(self.state, State) else self.state
|
| 120 |
+
return d
|
| 121 |
+
|
| 122 |
+
|
| 123 |
+
# ── HTTP helpers (single egress, brief no-op here; subclasses add caching) ─
|
| 124 |
+
def http_json(url: str, headers: dict | None = None, method: str = "GET",
|
| 125 |
+
data: bytes | None = None, timeout: float = _TIMEOUT) -> tuple[int, Any]:
|
| 126 |
+
"""Return (status_code, parsed_json_or_text). Never raises into the request path."""
|
| 127 |
+
req = _ur.Request(url, headers={"User-Agent": _UA, **(headers or {})},
|
| 128 |
+
method=method, data=data)
|
| 129 |
+
try:
|
| 130 |
+
with _ur.urlopen(req, timeout=timeout) as resp:
|
| 131 |
+
raw = resp.read().decode("utf-8", "replace")
|
| 132 |
+
try:
|
| 133 |
+
return resp.status, json.loads(raw)
|
| 134 |
+
except Exception:
|
| 135 |
+
return resp.status, raw
|
| 136 |
+
except _ue.HTTPError as e:
|
| 137 |
+
try:
|
| 138 |
+
body = e.read().decode("utf-8", "replace")
|
| 139 |
+
except Exception:
|
| 140 |
+
body = ""
|
| 141 |
+
return e.code, body
|
| 142 |
+
except Exception as e:
|
| 143 |
+
return 0, str(e)
|
| 144 |
+
|
| 145 |
+
|
| 146 |
+
def http_text(url: str, headers: dict | None = None, timeout: float = _TIMEOUT) -> tuple[int, str]:
|
| 147 |
+
req = _ur.Request(url, headers={"User-Agent": _UA, **(headers or {})})
|
| 148 |
+
try:
|
| 149 |
+
with _ur.urlopen(req, timeout=timeout) as resp:
|
| 150 |
+
return resp.status, resp.read().decode("utf-8", "replace")
|
| 151 |
+
except _ue.HTTPError as e:
|
| 152 |
+
return e.code, ""
|
| 153 |
+
except Exception as e:
|
| 154 |
+
return 0, str(e)
|
| 155 |
+
|
| 156 |
+
|
| 157 |
+
def cred_fingerprint(value: str) -> str:
|
| 158 |
+
"""SHA-256 fingerprint of a credential — what a receipt may carry. NEVER the value."""
|
| 159 |
+
return "sha256:" + hashlib.sha256(value.encode("utf-8")).hexdigest()[:32]
|
| 160 |
+
|
| 161 |
+
|
| 162 |
+
# ── the honesty core — resolve_state() ─────────────────────────────────────
|
| 163 |
+
def resolve_state(*, auth_kind: AuthKind, missing_env: list[str],
|
| 164 |
+
has_fixture: bool, free_tier: bool,
|
| 165 |
+
probe_ok: bool | None, probe_detail: str = "",
|
| 166 |
+
) -> tuple[State, str, str | None]:
|
| 167 |
+
"""The single decision function every connector uses to report an honest state.
|
| 168 |
+
|
| 169 |
+
Returns (state, detail, sample_reason).
|
| 170 |
+
|
| 171 |
+
Rules (verbatim from ENTERPRISE_INTEGRATION_SPEC §1.1):
|
| 172 |
+
missing_env != [] → READY (render "provide credentials")
|
| 173 |
+
auth_kind == "none" and provider reachable → CONNECTED (keyless live NOW)
|
| 174 |
+
creds present and health() 200 → CONNECTED
|
| 175 |
+
creds present and health() fails → ERROR (honest reason)
|
| 176 |
+
no free tier AND no creds AND fixture set → SAMPLE (sample_reason mandatory)
|
| 177 |
+
"""
|
| 178 |
+
# keyless: reachability decides
|
| 179 |
+
if auth_kind == "none":
|
| 180 |
+
if probe_ok is True:
|
| 181 |
+
return State.CONNECTED, probe_detail or "keyless public API reachable — live data", None
|
| 182 |
+
if probe_ok is False:
|
| 183 |
+
if has_fixture:
|
| 184 |
+
return (State.SAMPLE,
|
| 185 |
+
probe_detail or "keyless provider unreachable; labelled sample",
|
| 186 |
+
"provider unreachable at request time")
|
| 187 |
+
return State.ERROR, probe_detail or "keyless provider unreachable", None
|
| 188 |
+
# probe not run (manifest cheap path) → optimistic CONNECTED label, verified on read
|
| 189 |
+
return State.CONNECTED, "keyless public API — verified live on read", None
|
| 190 |
+
|
| 191 |
+
# credentialed connectors
|
| 192 |
+
if missing_env:
|
| 193 |
+
# no creds yet
|
| 194 |
+
if has_fixture and not free_tier:
|
| 195 |
+
return (State.SAMPLE, "no free tier; labelled sample until credentials provided",
|
| 196 |
+
"no free tier — connect to activate")
|
| 197 |
+
secret_list = ", ".join(missing_env)
|
| 198 |
+
return (State.READY, f"provide credentials to activate — set {secret_list}", None)
|
| 199 |
+
|
| 200 |
+
# creds present → reachability decides
|
| 201 |
+
if probe_ok is True:
|
| 202 |
+
return State.CONNECTED, probe_detail or "credentials present + provider reachable — live data", None
|
| 203 |
+
if probe_ok is False:
|
| 204 |
+
return State.ERROR, probe_detail or "credentials present but provider unreachable/denied", None
|
| 205 |
+
# creds present, probe deferred
|
| 206 |
+
return State.CONNECTED, "credentials present — verified live on read", None
|
| 207 |
+
|
| 208 |
+
|
| 209 |
+
# ── the base Connector ─────────────────────────────────────────────────────
|
| 210 |
+
class Connector:
|
| 211 |
+
"""Base. Subclass per provider. Real HTTP only; honest states only.
|
| 212 |
+
|
| 213 |
+
Subclass MUST set the class attributes below and override `read()`
|
| 214 |
+
(and `write()` if the connector is writable). `health()` has a sane
|
| 215 |
+
default that calls `_probe()` (override `_probe()` for a cheap reachability
|
| 216 |
+
check).
|
| 217 |
+
"""
|
| 218 |
+
id: str = "base"
|
| 219 |
+
category: str = "data_source" # crm|erp|identity|comms|itsm|warehouse|storage|observability|data_source|mesh|maritime|air|geo|vuln
|
| 220 |
+
auth_kind: AuthKind = "none"
|
| 221 |
+
env_vars: list[str] = [] # secret names; NEVER hardcode the values
|
| 222 |
+
provider_base: str = "" # real API base URL
|
| 223 |
+
free_tier: bool = False # True if a free dev sandbox / open demo / keyless exists
|
| 224 |
+
writable: bool = False
|
| 225 |
+
mcp_tool: str = "szl_connector_read"
|
| 226 |
+
sample_records: list[dict[str, Any]] | None = None
|
| 227 |
+
sample_reason_text: str | None = None
|
| 228 |
+
schema_preview: list[str] = [] # column headers the tile shows in READY state
|
| 229 |
+
docs_url: str = "" # "verify it yourself" link
|
| 230 |
+
label: str = "" # human display name
|
| 231 |
+
|
| 232 |
+
# ── credential resolution (env-only) ──────────────────────────────────
|
| 233 |
+
def _creds(self) -> dict[str, str]:
|
| 234 |
+
return {k: os.environ[k] for k in self.env_vars if os.environ.get(k)}
|
| 235 |
+
|
| 236 |
+
def _missing_env(self) -> list[str]:
|
| 237 |
+
# OAuth2 connectors: only the CLIENT_ID/SECRET (+ refresh) gate activation;
|
| 238 |
+
# we report ALL declared env_vars that are absent.
|
| 239 |
+
return [k for k in self.env_vars if not os.environ.get(k)]
|
| 240 |
+
|
| 241 |
+
# ── cheap reachability probe (override per provider) ──────────────────
|
| 242 |
+
def _probe(self) -> tuple[bool | None, str]:
|
| 243 |
+
"""Return (probe_ok, detail). None = deferred (manifest cheap path)."""
|
| 244 |
+
return None, ""
|
| 245 |
+
|
| 246 |
+
# ── default health() using resolve_state() ────────────────────────────
|
| 247 |
+
def health(self, *, probe: bool = False) -> HealthReport:
|
| 248 |
+
missing = self._missing_env()
|
| 249 |
+
probe_ok: bool | None = None
|
| 250 |
+
detail_probe = ""
|
| 251 |
+
latency = None
|
| 252 |
+
if probe:
|
| 253 |
+
# only probe if keyless OR creds present
|
| 254 |
+
if self.auth_kind == "none" or not missing:
|
| 255 |
+
t0 = time.time()
|
| 256 |
+
probe_ok, detail_probe = self._probe()
|
| 257 |
+
latency = round((time.time() - t0) * 1000, 1)
|
| 258 |
+
state, detail, sample_reason = resolve_state(
|
| 259 |
+
auth_kind=self.auth_kind, missing_env=missing,
|
| 260 |
+
has_fixture=bool(self.sample_records), free_tier=self.free_tier,
|
| 261 |
+
probe_ok=probe_ok, probe_detail=detail_probe,
|
| 262 |
+
)
|
| 263 |
+
return HealthReport(
|
| 264 |
+
connector_id=self.id, state=state, auth_kind=self.auth_kind,
|
| 265 |
+
env_vars=list(self.env_vars), missing_env=missing,
|
| 266 |
+
provider_base=self.provider_base, detail=detail,
|
| 267 |
+
latency_ms=latency, sample_reason=sample_reason or self.sample_reason_text,
|
| 268 |
+
free_tier=self.free_tier,
|
| 269 |
+
)
|
| 270 |
+
|
| 271 |
+
# ── read() — subclasses override; default returns honest READY/SAMPLE ─
|
| 272 |
+
def read(self, query: dict | None = None) -> Records:
|
| 273 |
+
h = self.health(probe=True)
|
| 274 |
+
if h.state == State.SAMPLE and self.sample_records:
|
| 275 |
+
return Records(
|
| 276 |
+
connector_id=self.id, category=self.category, state=State.SAMPLE,
|
| 277 |
+
records=list(self.sample_records), source=f"{self.label or self.id} (labelled SAMPLE)",
|
| 278 |
+
live=False, note=h.sample_reason or "labelled sample (no free tier)",
|
| 279 |
+
schema_preview=list(self.schema_preview),
|
| 280 |
+
)
|
| 281 |
+
# READY / ERROR → zero rows, honest note (NEVER fabricated)
|
| 282 |
+
return Records(
|
| 283 |
+
connector_id=self.id, category=self.category, state=h.state,
|
| 284 |
+
records=[], source=self.provider_base, live=False, note=h.detail,
|
| 285 |
+
schema_preview=list(self.schema_preview),
|
| 286 |
+
)
|
| 287 |
+
|
| 288 |
+
def write(self, action: dict | None = None) -> WriteResult:
|
| 289 |
+
raise NotImplementedError(f"connector {self.id} is read-only")
|
| 290 |
+
|
| 291 |
+
# ── convenience: a READY/SAMPLE Records helper for subclasses ─────────
|
| 292 |
+
def _ready_records(self, detail: str) -> Records:
|
| 293 |
+
return Records(
|
| 294 |
+
connector_id=self.id, category=self.category, state=State.READY,
|
| 295 |
+
records=[], source=self.provider_base, live=False, note=detail,
|
| 296 |
+
schema_preview=list(self.schema_preview),
|
| 297 |
+
)
|
| 298 |
+
|
| 299 |
+
|
| 300 |
+
__all__ = [
|
| 301 |
+
"AuthKind", "State", "HealthReport", "Records", "WriteResult",
|
| 302 |
+
"Connector", "resolve_state", "http_json", "http_text",
|
| 303 |
+
"cred_fingerprint", "_now",
|
| 304 |
+
]
|
|
@@ -0,0 +1,41 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# SPDX-License-Identifier: Apache-2.0
|
| 2 |
+
# © 2026 Lutar, Stephen P. — SZL Holdings · ORCID 0009-0001-0110-4173 · Doctrine v11
|
| 3 |
+
# Authored by SZL Enterprise-Integration Team. Co-Authored-By: Perplexity Computer Agent.
|
| 4 |
+
"""szl_connectors.bindings — which tab/vertical binds to which connector(s).
|
| 5 |
+
|
| 6 |
+
A tab declares its connector binding here; the tab renders from each connector's
|
| 7 |
+
`Records` and its honest `state`. A tab bound to a keyless data source is LIVE on
|
| 8 |
+
first paint; a tab bound to a CRM/ERP shows the READY "connect to enable" state
|
| 9 |
+
with the real schema preview until the customer meshes in.
|
| 10 |
+
"""
|
| 11 |
+
from __future__ import annotations
|
| 12 |
+
|
| 13 |
+
TAB_BINDINGS: dict[str, list[str]] = {
|
| 14 |
+
# a11oy governed-AI / security verticals (keyless → CONNECTED now)
|
| 15 |
+
"vuln": ["nvd_cve", "cisa_kev", "epss"],
|
| 16 |
+
"attack": ["mitre_attack"],
|
| 17 |
+
"macro": ["fred", "sec_edgar"],
|
| 18 |
+
"research": ["arxiv", "hf_hub", "github"],
|
| 19 |
+
"ontology": ["wikidata"],
|
| 20 |
+
# enterprise CRM / ERP / platform verticals (READY until customer creds)
|
| 21 |
+
"crm": ["salesforce", "hubspot", "dynamics_crm", "zoho_crm", "pipedrive",
|
| 22 |
+
"close", "freshsales", "sugarcrm"],
|
| 23 |
+
"erp": ["odoo", "erpnext", "sap_s4", "netsuite", "dynamics_erp",
|
| 24 |
+
"sage", "acumatica", "infor"],
|
| 25 |
+
"identity": ["okta", "entra", "auth0"],
|
| 26 |
+
"comms": ["slack", "teams", "email"],
|
| 27 |
+
"tickets": ["jira", "servicenow", "zendesk", "linear"],
|
| 28 |
+
"warehouse": ["snowflake", "databricks", "bigquery", "postgres"],
|
| 29 |
+
"storage": ["s3", "gcs", "azure_blob"],
|
| 30 |
+
"observ": ["datadog", "splunk", "grafana"],
|
| 31 |
+
# killinchu maritime / air / geo verticals
|
| 32 |
+
"maritime": ["opensky", "aisstream", "noaa"],
|
| 33 |
+
"geo": ["overpass", "usgs", "wikidata"],
|
| 34 |
+
}
|
| 35 |
+
|
| 36 |
+
|
| 37 |
+
def tabs_for(connector_id: str) -> list[str]:
|
| 38 |
+
return [t for t, ids in TAB_BINDINGS.items() if connector_id in ids]
|
| 39 |
+
|
| 40 |
+
|
| 41 |
+
__all__ = ["TAB_BINDINGS", "tabs_for"]
|
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# SPDX-License-Identifier: Apache-2.0
|
| 2 |
+
# © 2026 SZL Holdings. Comms connectors (Slack/Teams — credential-READY).
|
| 3 |
+
from . import comms_connectors # noqa: F401
|
|
@@ -0,0 +1,83 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# SPDX-License-Identifier: Apache-2.0
|
| 2 |
+
# © 2026 Lutar, Stephen P. — SZL Holdings · Doctrine v11 · Perplexity Computer Agent.
|
| 3 |
+
"""Comms connectors — Slack, Microsoft Teams (Graph).
|
| 4 |
+
|
| 5 |
+
REAL clients against documented endpoints. NO creds → READY + exact secret name.
|
| 6 |
+
Slack is WRITABLE (post message) — every write is Λ-gated + DSSE-receipted.
|
| 7 |
+
|
| 8 |
+
API refs (publicly documented shapes):
|
| 9 |
+
Slack Web API https://api.slack.com/methods/conversations.list
|
| 10 |
+
MS Teams (Graph) https://learn.microsoft.com/graph/api/team-list
|
| 11 |
+
"""
|
| 12 |
+
from __future__ import annotations
|
| 13 |
+
|
| 14 |
+
import os
|
| 15 |
+
|
| 16 |
+
from ..base import State, Records, http_json
|
| 17 |
+
from ..ready import ReadyConnector, WritableReadyConnector
|
| 18 |
+
from ..registry import register
|
| 19 |
+
|
| 20 |
+
|
| 21 |
+
# ── Slack (api_key bot token; free workspace) — WRITABLE ──────────────────────
|
| 22 |
+
@register
|
| 23 |
+
class SlackConnector(WritableReadyConnector):
|
| 24 |
+
id = "slack"
|
| 25 |
+
label = "Slack"
|
| 26 |
+
category = "comms"
|
| 27 |
+
auth_kind = "oauth2" # bot token via OAuth install
|
| 28 |
+
free_tier = True
|
| 29 |
+
env_vars = ["SZL_SLACK_BOT_TOKEN"]
|
| 30 |
+
_primary_secret = "SZL_SLACK_BOT_TOKEN"
|
| 31 |
+
provider_base = "https://slack.com/api"
|
| 32 |
+
docs_url = "https://api.slack.com/methods/conversations.list"
|
| 33 |
+
schema_preview = ["id", "name", "is_channel", "num_members"]
|
| 34 |
+
_read_path = "conversations.list?limit=10"
|
| 35 |
+
_record_path = "channels"
|
| 36 |
+
_write_path = "chat.postMessage"
|
| 37 |
+
|
| 38 |
+
def _auth_header(self):
|
| 39 |
+
tok = os.environ.get("SZL_SLACK_BOT_TOKEN")
|
| 40 |
+
return {"Authorization": f"Bearer {tok}"} if tok else {}
|
| 41 |
+
|
| 42 |
+
def read(self, query=None):
|
| 43 |
+
if self._primary_missing():
|
| 44 |
+
return self._ready_records(
|
| 45 |
+
"provide credentials to activate — set SZL_SLACK_BOT_TOKEN "
|
| 46 |
+
"(scopes channels:read,chat:write). Hits conversations.list.")
|
| 47 |
+
url = self._base_url() + "/conversations.list?limit=10"
|
| 48 |
+
st, raw = http_json(url, headers={"Accept": "application/json", **self._auth_header()})
|
| 49 |
+
if st == 200 and isinstance(raw, dict) and raw.get("ok"):
|
| 50 |
+
rows = raw.get("channels", []) or []
|
| 51 |
+
proj = [{k: r.get(k) for k in self.schema_preview if k in r} for r in rows[:10]]
|
| 52 |
+
return Records(connector_id=self.id, category=self.category, state=State.CONNECTED,
|
| 53 |
+
records=proj, source="Slack conversations.list", live=True,
|
| 54 |
+
note=f"live · {len(rows)} channels", schema_preview=self.schema_preview)
|
| 55 |
+
err = raw.get("error") if isinstance(raw, dict) else st
|
| 56 |
+
return Records(connector_id=self.id, category=self.category, state=State.ERROR,
|
| 57 |
+
records=[], source=self.provider_base, live=False,
|
| 58 |
+
note=f"credentials present but Slack returned {err}",
|
| 59 |
+
schema_preview=self.schema_preview)
|
| 60 |
+
|
| 61 |
+
|
| 62 |
+
# ── Microsoft Teams (Graph; oauth2) ───────────────────────────────────────────
|
| 63 |
+
@register
|
| 64 |
+
class TeamsConnector(ReadyConnector):
|
| 65 |
+
id = "teams"
|
| 66 |
+
label = "Microsoft Teams"
|
| 67 |
+
category = "comms"
|
| 68 |
+
auth_kind = "oauth2"
|
| 69 |
+
free_tier = True
|
| 70 |
+
env_vars = ["SZL_TEAMS_ACCESS_TOKEN", "SZL_TEAMS_TENANT_ID"]
|
| 71 |
+
_primary_secret = "SZL_TEAMS_ACCESS_TOKEN"
|
| 72 |
+
provider_base = "https://graph.microsoft.com/v1.0"
|
| 73 |
+
docs_url = "https://learn.microsoft.com/graph/api/team-list"
|
| 74 |
+
schema_preview = ["id", "displayName", "description"]
|
| 75 |
+
_read_path = "me/joinedTeams"
|
| 76 |
+
_record_path = "value"
|
| 77 |
+
|
| 78 |
+
def _auth_header(self):
|
| 79 |
+
tok = os.environ.get("SZL_TEAMS_ACCESS_TOKEN")
|
| 80 |
+
return {"Authorization": f"Bearer {tok}"} if tok else {}
|
| 81 |
+
|
| 82 |
+
|
| 83 |
+
__all__ = ["SlackConnector", "TeamsConnector"]
|
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# SPDX-License-Identifier: Apache-2.0
|
| 2 |
+
# © 2026 SZL Holdings. CRM connectors (READY until creds; SF/HubSpot free-org-able).
|
| 3 |
+
from . import crm_connectors # noqa: F401
|
|
@@ -0,0 +1,254 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# SPDX-License-Identifier: Apache-2.0
|
| 2 |
+
# © 2026 Lutar, Stephen P. — SZL Holdings · Doctrine v11 · Perplexity Computer Agent.
|
| 3 |
+
"""CRM connectors — built + tested against each vendor's documented API shape.
|
| 4 |
+
|
| 5 |
+
Each is a REAL client (documented endpoint + record shape + auth header). With NO
|
| 6 |
+
creds → state=READY + exact secret name; NEVER fabricates a record. The moment
|
| 7 |
+
the customer's credentials land in the Space secret → CONNECTED → live data.
|
| 8 |
+
|
| 9 |
+
API refs (publicly documented shapes; SZL writes its own original client code):
|
| 10 |
+
Salesforce REST https://developer.salesforce.com/docs/atlas.en-us.api_rest.meta/api_rest/resources_list.htm
|
| 11 |
+
HubSpot CRM v3 https://developers.hubspot.com/docs/api-reference/latest/overview
|
| 12 |
+
Dynamics 365 https://learn.microsoft.com/dynamics365/ (OData v9.2)
|
| 13 |
+
Zoho CRM v8 https://www.zoho.com/crm/developer/docs/api/v8/
|
| 14 |
+
Pipedrive v2 https://developers.pipedrive.com/docs/api/v1
|
| 15 |
+
Close https://developer.close.com/
|
| 16 |
+
Freshsales https://developers.freshworks.com/crm/api/
|
| 17 |
+
SugarCRM v11 https://support.sugarcrm.com/documentation/
|
| 18 |
+
"""
|
| 19 |
+
from __future__ import annotations
|
| 20 |
+
|
| 21 |
+
import os
|
| 22 |
+
|
| 23 |
+
from ..base import State, Records, WriteResult, http_json, cred_fingerprint
|
| 24 |
+
from ..ready import ReadyConnector, WritableReadyConnector
|
| 25 |
+
from ..registry import register
|
| 26 |
+
from ..governance import gate_write
|
| 27 |
+
|
| 28 |
+
|
| 29 |
+
# ── Salesforce (oauth2; free Developer Edition) ──────────────────────────────
|
| 30 |
+
@register
|
| 31 |
+
class SalesforceConnector(WritableReadyConnector):
|
| 32 |
+
id = "salesforce"
|
| 33 |
+
label = "Salesforce"
|
| 34 |
+
category = "crm"
|
| 35 |
+
auth_kind = "oauth2"
|
| 36 |
+
free_tier = True # Developer Edition (free)
|
| 37 |
+
env_vars = ["SZL_SALESFORCE_INSTANCE_URL", "SZL_SALESFORCE_ACCESS_TOKEN",
|
| 38 |
+
"SZL_SALESFORCE_CLIENT_ID", "SZL_SALESFORCE_CLIENT_SECRET",
|
| 39 |
+
"SZL_SALESFORCE_REFRESH_TOKEN"]
|
| 40 |
+
_primary_secret = "SZL_SALESFORCE_ACCESS_TOKEN"
|
| 41 |
+
provider_base = "{instance_url}/services/data/v60.0"
|
| 42 |
+
docs_url = "https://developer.salesforce.com/docs/atlas.en-us.api_rest.meta/api_rest/resources_list.htm"
|
| 43 |
+
schema_preview = ["Id", "Name", "Account", "Email", "Owner"]
|
| 44 |
+
_read_path = "query?q=" + "SELECT+Id,Name,Email+FROM+Contact+LIMIT+10"
|
| 45 |
+
_record_path = "records"
|
| 46 |
+
_record_fields = ["Id", "Name", "Email"]
|
| 47 |
+
_write_path = "sobjects/Contact"
|
| 48 |
+
|
| 49 |
+
def _base_url(self):
|
| 50 |
+
inst = os.environ.get("SZL_SALESFORCE_INSTANCE_URL", "")
|
| 51 |
+
return self.provider_base.replace("{instance_url}", inst)
|
| 52 |
+
|
| 53 |
+
def _auth_header(self):
|
| 54 |
+
tok = os.environ.get("SZL_SALESFORCE_ACCESS_TOKEN")
|
| 55 |
+
return {"Authorization": f"Bearer {tok}"} if tok else {}
|
| 56 |
+
|
| 57 |
+
|
| 58 |
+
# ── HubSpot (api_key private-app token OR oauth2; free dev account) ───────────
|
| 59 |
+
@register
|
| 60 |
+
class HubSpotConnector(WritableReadyConnector):
|
| 61 |
+
id = "hubspot"
|
| 62 |
+
label = "HubSpot"
|
| 63 |
+
category = "crm"
|
| 64 |
+
auth_kind = "api_key" # private-app token; oauth2 also supported
|
| 65 |
+
free_tier = True
|
| 66 |
+
env_vars = ["SZL_HUBSPOT_API_KEY"]
|
| 67 |
+
_primary_secret = "SZL_HUBSPOT_API_KEY"
|
| 68 |
+
provider_base = "https://api.hubapi.com"
|
| 69 |
+
docs_url = "https://developers.hubspot.com/docs/api-reference/latest/overview"
|
| 70 |
+
schema_preview = ["id", "firstname", "lastname", "email", "company"]
|
| 71 |
+
_read_path = "crm/v3/objects/contacts?limit=10&properties=firstname,lastname,email,company"
|
| 72 |
+
_record_path = "results"
|
| 73 |
+
_write_path = "crm/v3/objects/contacts"
|
| 74 |
+
|
| 75 |
+
def _auth_header(self):
|
| 76 |
+
tok = os.environ.get("SZL_HUBSPOT_API_KEY")
|
| 77 |
+
return {"Authorization": f"Bearer {tok}"} if tok else {}
|
| 78 |
+
|
| 79 |
+
def _dig(self, raw):
|
| 80 |
+
rows = (raw or {}).get("results", []) if isinstance(raw, dict) else []
|
| 81 |
+
out = []
|
| 82 |
+
for r in rows:
|
| 83 |
+
p = r.get("properties", {}) if isinstance(r, dict) else {}
|
| 84 |
+
out.append({"id": r.get("id"), **{k: p.get(k) for k in
|
| 85 |
+
("firstname", "lastname", "email", "company")}})
|
| 86 |
+
return out
|
| 87 |
+
|
| 88 |
+
|
| 89 |
+
# ── Dynamics 365 Sales (oauth2/Entra; OData v9.2) ────────────────────────────
|
| 90 |
+
@register
|
| 91 |
+
class Dynamics365Connector(ReadyConnector):
|
| 92 |
+
id = "dynamics_crm"
|
| 93 |
+
label = "Microsoft Dynamics 365 (Sales)"
|
| 94 |
+
category = "crm"
|
| 95 |
+
auth_kind = "oauth2"
|
| 96 |
+
free_tier = False
|
| 97 |
+
env_vars = ["SZL_DYNAMICS_CRM_ORG_URL", "SZL_DYNAMICS_CRM_ACCESS_TOKEN",
|
| 98 |
+
"SZL_DYNAMICS_CRM_CLIENT_ID", "SZL_DYNAMICS_CRM_CLIENT_SECRET",
|
| 99 |
+
"SZL_DYNAMICS_CRM_TENANT_ID"]
|
| 100 |
+
_primary_secret = "SZL_DYNAMICS_CRM_ACCESS_TOKEN"
|
| 101 |
+
provider_base = "{org_url}/api/data/v9.2"
|
| 102 |
+
docs_url = "https://learn.microsoft.com/power-apps/developer/data-platform/webapi/overview"
|
| 103 |
+
schema_preview = ["contactid", "fullname", "emailaddress1", "telephone1"]
|
| 104 |
+
_read_path = "contacts?$top=10&$select=fullname,emailaddress1,telephone1"
|
| 105 |
+
_record_path = "value"
|
| 106 |
+
|
| 107 |
+
def _base_url(self):
|
| 108 |
+
return self.provider_base.replace("{org_url}", os.environ.get("SZL_DYNAMICS_CRM_ORG_URL", ""))
|
| 109 |
+
|
| 110 |
+
def _auth_header(self):
|
| 111 |
+
tok = os.environ.get("SZL_DYNAMICS_CRM_ACCESS_TOKEN")
|
| 112 |
+
return {"Authorization": f"Bearer {tok}", "OData-MaxVersion": "4.0", "OData-Version": "4.0"} if tok else {}
|
| 113 |
+
|
| 114 |
+
|
| 115 |
+
# ── Zoho CRM v8 (oauth2; free edition + sandbox) ─────────────────────────────
|
| 116 |
+
@register
|
| 117 |
+
class ZohoCrmConnector(ReadyConnector):
|
| 118 |
+
id = "zoho_crm"
|
| 119 |
+
label = "Zoho CRM"
|
| 120 |
+
category = "crm"
|
| 121 |
+
auth_kind = "oauth2"
|
| 122 |
+
free_tier = True
|
| 123 |
+
env_vars = ["SZL_ZOHO_CRM_ACCESS_TOKEN", "SZL_ZOHO_CRM_CLIENT_ID",
|
| 124 |
+
"SZL_ZOHO_CRM_CLIENT_SECRET", "SZL_ZOHO_CRM_REFRESH_TOKEN"]
|
| 125 |
+
_primary_secret = "SZL_ZOHO_CRM_ACCESS_TOKEN"
|
| 126 |
+
provider_base = "https://www.zohoapis.com/crm/v8"
|
| 127 |
+
docs_url = "https://www.zoho.com/crm/developer/docs/api/v8/"
|
| 128 |
+
schema_preview = ["id", "Full_Name", "Email", "Account_Name"]
|
| 129 |
+
_read_path = "Contacts?fields=Full_Name,Email,Account_Name&per_page=10"
|
| 130 |
+
_record_path = "data"
|
| 131 |
+
|
| 132 |
+
def _auth_header(self):
|
| 133 |
+
tok = os.environ.get("SZL_ZOHO_CRM_ACCESS_TOKEN")
|
| 134 |
+
return {"Authorization": f"Zoho-oauthtoken {tok}"} if tok else {}
|
| 135 |
+
|
| 136 |
+
|
| 137 |
+
# ── Pipedrive v2 (oauth2/api_key) ────────────────────────────────────────────
|
| 138 |
+
@register
|
| 139 |
+
class PipedriveConnector(ReadyConnector):
|
| 140 |
+
id = "pipedrive"
|
| 141 |
+
label = "Pipedrive"
|
| 142 |
+
category = "crm"
|
| 143 |
+
auth_kind = "api_key"
|
| 144 |
+
free_tier = False
|
| 145 |
+
env_vars = ["SZL_PIPEDRIVE_API_TOKEN", "SZL_PIPEDRIVE_COMPANY_DOMAIN"]
|
| 146 |
+
_primary_secret = "SZL_PIPEDRIVE_API_TOKEN"
|
| 147 |
+
provider_base = "https://{domain}.pipedrive.com/api/v2"
|
| 148 |
+
docs_url = "https://developers.pipedrive.com/docs/api/v1"
|
| 149 |
+
schema_preview = ["id", "name", "email", "org_id"]
|
| 150 |
+
_read_path = "persons?limit=10"
|
| 151 |
+
_record_path = "data"
|
| 152 |
+
|
| 153 |
+
def _base_url(self):
|
| 154 |
+
return self.provider_base.replace("{domain}", os.environ.get("SZL_PIPEDRIVE_COMPANY_DOMAIN", "api"))
|
| 155 |
+
|
| 156 |
+
def _auth_header(self):
|
| 157 |
+
# Pipedrive uses api_token query param; we also support Bearer
|
| 158 |
+
return {}
|
| 159 |
+
|
| 160 |
+
def read(self, query=None):
|
| 161 |
+
tok = os.environ.get("SZL_PIPEDRIVE_API_TOKEN")
|
| 162 |
+
if not tok:
|
| 163 |
+
return self._ready_records(
|
| 164 |
+
"provide credentials to activate — set SZL_PIPEDRIVE_API_TOKEN, "
|
| 165 |
+
"SZL_PIPEDRIVE_COMPANY_DOMAIN. Hits /api/v2/persons.")
|
| 166 |
+
url = self._base_url() + "/persons?limit=10&api_token=" + tok
|
| 167 |
+
st, raw = http_json(url, headers={"Accept": "application/json"})
|
| 168 |
+
if st == 200 and isinstance(raw, dict):
|
| 169 |
+
rows = raw.get("data", []) or []
|
| 170 |
+
proj = [{k: r.get(k) for k in self.schema_preview if k in r} for r in rows[:10]]
|
| 171 |
+
return Records(connector_id=self.id, category=self.category, state=State.CONNECTED,
|
| 172 |
+
records=proj, source=f"Pipedrive {url.split('?')[0]}", live=True,
|
| 173 |
+
note=f"live · {len(rows)} persons", schema_preview=self.schema_preview)
|
| 174 |
+
return Records(connector_id=self.id, category=self.category, state=State.ERROR,
|
| 175 |
+
records=[], source=self.provider_base, live=False,
|
| 176 |
+
note=f"credentials present but Pipedrive HTTP {st}", schema_preview=self.schema_preview)
|
| 177 |
+
|
| 178 |
+
|
| 179 |
+
# ── Close (api_key, basic) ───────────────────────────────────────────────────
|
| 180 |
+
@register
|
| 181 |
+
class CloseConnector(ReadyConnector):
|
| 182 |
+
id = "close"
|
| 183 |
+
label = "Close CRM"
|
| 184 |
+
category = "crm"
|
| 185 |
+
auth_kind = "api_key"
|
| 186 |
+
free_tier = False
|
| 187 |
+
env_vars = ["SZL_CLOSE_API_KEY"]
|
| 188 |
+
_primary_secret = "SZL_CLOSE_API_KEY"
|
| 189 |
+
provider_base = "https://api.close.com/api/v1"
|
| 190 |
+
docs_url = "https://developer.close.com/"
|
| 191 |
+
schema_preview = ["id", "name", "display_name"]
|
| 192 |
+
_read_path = "contact/?_limit=10"
|
| 193 |
+
_record_path = "data"
|
| 194 |
+
|
| 195 |
+
def _auth_header(self):
|
| 196 |
+
import base64
|
| 197 |
+
key = os.environ.get("SZL_CLOSE_API_KEY")
|
| 198 |
+
if not key:
|
| 199 |
+
return {}
|
| 200 |
+
b = base64.b64encode(f"{key}:".encode()).decode()
|
| 201 |
+
return {"Authorization": f"Basic {b}"}
|
| 202 |
+
|
| 203 |
+
|
| 204 |
+
# ── Freshsales (api_key token header; free plan) ─────────────────────────────
|
| 205 |
+
@register
|
| 206 |
+
class FreshsalesConnector(ReadyConnector):
|
| 207 |
+
id = "freshsales"
|
| 208 |
+
label = "Freshsales (Freshworks CRM)"
|
| 209 |
+
category = "crm"
|
| 210 |
+
auth_kind = "api_key"
|
| 211 |
+
free_tier = True
|
| 212 |
+
env_vars = ["SZL_FRESHSALES_API_KEY", "SZL_FRESHSALES_DOMAIN"]
|
| 213 |
+
_primary_secret = "SZL_FRESHSALES_API_KEY"
|
| 214 |
+
provider_base = "https://{domain}.myfreshworks.com/crm/sales/api"
|
| 215 |
+
docs_url = "https://developers.freshworks.com/crm/api/"
|
| 216 |
+
schema_preview = ["id", "display_name", "email", "mobile_number"]
|
| 217 |
+
_read_path = "contacts/view/0"
|
| 218 |
+
_record_path = "contacts"
|
| 219 |
+
|
| 220 |
+
def _base_url(self):
|
| 221 |
+
return self.provider_base.replace("{domain}", os.environ.get("SZL_FRESHSALES_DOMAIN", "api"))
|
| 222 |
+
|
| 223 |
+
def _auth_header(self):
|
| 224 |
+
key = os.environ.get("SZL_FRESHSALES_API_KEY")
|
| 225 |
+
return {"Authorization": f"Token token={key}"} if key else {}
|
| 226 |
+
|
| 227 |
+
|
| 228 |
+
# ── SugarCRM v11 (oauth2 password/refresh) ───────────────────────────────────
|
| 229 |
+
@register
|
| 230 |
+
class SugarCrmConnector(ReadyConnector):
|
| 231 |
+
id = "sugarcrm"
|
| 232 |
+
label = "SugarCRM"
|
| 233 |
+
category = "crm"
|
| 234 |
+
auth_kind = "oauth2"
|
| 235 |
+
free_tier = False
|
| 236 |
+
env_vars = ["SZL_SUGARCRM_SITE_URL", "SZL_SUGARCRM_ACCESS_TOKEN"]
|
| 237 |
+
_primary_secret = "SZL_SUGARCRM_ACCESS_TOKEN"
|
| 238 |
+
provider_base = "{site_url}/rest/v11_24"
|
| 239 |
+
docs_url = "https://support.sugarcrm.com/documentation/"
|
| 240 |
+
schema_preview = ["id", "name", "email1", "account_name"]
|
| 241 |
+
_read_path = "Contacts?max_num=10&fields=name,email1,account_name"
|
| 242 |
+
_record_path = "records"
|
| 243 |
+
|
| 244 |
+
def _base_url(self):
|
| 245 |
+
return self.provider_base.replace("{site_url}", os.environ.get("SZL_SUGARCRM_SITE_URL", ""))
|
| 246 |
+
|
| 247 |
+
def _auth_header(self):
|
| 248 |
+
tok = os.environ.get("SZL_SUGARCRM_ACCESS_TOKEN")
|
| 249 |
+
return {"OAuth-Token": tok} if tok else {}
|
| 250 |
+
|
| 251 |
+
|
| 252 |
+
__all__ = ["SalesforceConnector", "HubSpotConnector", "Dynamics365Connector",
|
| 253 |
+
"ZohoCrmConnector", "PipedriveConnector", "CloseConnector",
|
| 254 |
+
"FreshsalesConnector", "SugarCrmConnector"]
|
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# SPDX-License-Identifier: Apache-2.0
|
| 2 |
+
# © 2026 SZL Holdings. P0 free/no-signup live data-source connectors.
|
| 3 |
+
from . import security, macro, research, maritime_air, geo # noqa: F401
|
|
@@ -0,0 +1,148 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# SPDX-License-Identifier: Apache-2.0
|
| 2 |
+
# © 2026 Lutar, Stephen P. — SZL Holdings · Doctrine v11 · Perplexity Computer Agent.
|
| 3 |
+
"""killinchu / shared geo live connectors (keyless → CONNECTED now).
|
| 4 |
+
|
| 5 |
+
usgs USGS earthquake feeds (GeoJSON) keyless
|
| 6 |
+
noaa NOAA api.weather.gov forecast/alerts keyless (UA header)
|
| 7 |
+
overpass OpenStreetMap Overpass API keyless
|
| 8 |
+
"""
|
| 9 |
+
from __future__ import annotations
|
| 10 |
+
|
| 11 |
+
import time
|
| 12 |
+
from typing import Any
|
| 13 |
+
|
| 14 |
+
from ..base import Connector, Records, State, http_json, http_text, _now
|
| 15 |
+
from ..registry import register
|
| 16 |
+
|
| 17 |
+
_CACHE: dict[str, tuple[float, Any]] = {}
|
| 18 |
+
|
| 19 |
+
|
| 20 |
+
def _cached(k, ttl):
|
| 21 |
+
h = _CACHE.get(k)
|
| 22 |
+
return h[1] if h and (time.time() - h[0]) < ttl else None
|
| 23 |
+
|
| 24 |
+
|
| 25 |
+
def _put(k, v):
|
| 26 |
+
_CACHE[k] = (time.time(), v)
|
| 27 |
+
|
| 28 |
+
|
| 29 |
+
@register
|
| 30 |
+
class UsgsConnector(Connector):
|
| 31 |
+
id = "usgs"
|
| 32 |
+
label = "USGS earthquakes"
|
| 33 |
+
category = "geo"
|
| 34 |
+
auth_kind = "none"
|
| 35 |
+
free_tier = True
|
| 36 |
+
provider_base = "https://earthquake.usgs.gov/earthquakes/feed/v1.0/summary/all_day.geojson"
|
| 37 |
+
docs_url = "https://earthquake.usgs.gov/earthquakes/feed/v1.0/geojson.php"
|
| 38 |
+
schema_preview = ["mag", "place", "time", "longitude", "latitude", "depth"]
|
| 39 |
+
|
| 40 |
+
def _probe(self):
|
| 41 |
+
st, _ = http_json(self.provider_base)
|
| 42 |
+
return (st == 200), f"USGS HTTP {st}"
|
| 43 |
+
|
| 44 |
+
def read(self, query: dict | None = None) -> Records:
|
| 45 |
+
window = (query or {}).get("window", "all_day") # all_day|2.5_week|4.5_month
|
| 46 |
+
limit = max(1, min(int((query or {}).get("limit", 15)), 50))
|
| 47 |
+
url = f"https://earthquake.usgs.gov/earthquakes/feed/v1.0/summary/{window}.geojson"
|
| 48 |
+
ck = f"usgs:{window}:{limit}"
|
| 49 |
+
c = _cached(ck, 120)
|
| 50 |
+
if c:
|
| 51 |
+
return c
|
| 52 |
+
st, raw = http_json(url)
|
| 53 |
+
if st == 200 and isinstance(raw, dict):
|
| 54 |
+
items = []
|
| 55 |
+
for f in (raw.get("features", []) or [])[:limit]:
|
| 56 |
+
p = f.get("properties", {}); g = f.get("geometry", {}).get("coordinates", [None, None, None])
|
| 57 |
+
items.append({"mag": p.get("mag"), "place": p.get("place"), "time": p.get("time"),
|
| 58 |
+
"longitude": g[0], "latitude": g[1], "depth": g[2]})
|
| 59 |
+
r = Records(connector_id=self.id, category=self.category, state=State.CONNECTED,
|
| 60 |
+
records=items, source=f"USGS {window} GeoJSON", live=True,
|
| 61 |
+
note=f"live · {len(raw.get('features', []))} events", schema_preview=self.schema_preview)
|
| 62 |
+
_put(ck, r)
|
| 63 |
+
return r
|
| 64 |
+
return self._ready_records(f"USGS unreachable (HTTP {st})")
|
| 65 |
+
|
| 66 |
+
|
| 67 |
+
@register
|
| 68 |
+
class NoaaConnector(Connector):
|
| 69 |
+
id = "noaa"
|
| 70 |
+
label = "NOAA weather (api.weather.gov)"
|
| 71 |
+
category = "geo"
|
| 72 |
+
auth_kind = "none"
|
| 73 |
+
free_tier = True
|
| 74 |
+
provider_base = "https://api.weather.gov"
|
| 75 |
+
docs_url = "https://www.weather.gov/documentation/services-web-api"
|
| 76 |
+
schema_preview = ["event", "severity", "area", "effective", "headline"]
|
| 77 |
+
|
| 78 |
+
def _probe(self):
|
| 79 |
+
st, _ = http_json(self.provider_base + "/alerts/active?limit=1")
|
| 80 |
+
return (st == 200), f"NOAA HTTP {st}"
|
| 81 |
+
|
| 82 |
+
def read(self, query: dict | None = None) -> Records:
|
| 83 |
+
limit = max(1, min(int((query or {}).get("limit", 12)), 50))
|
| 84 |
+
area = (query or {}).get("area", "")
|
| 85 |
+
url = f"{self.provider_base}/alerts/active?limit={limit}" + (f"&area={area}" if area else "")
|
| 86 |
+
ck = f"noaa:{area}:{limit}"
|
| 87 |
+
c = _cached(ck, 180)
|
| 88 |
+
if c:
|
| 89 |
+
return c
|
| 90 |
+
st, raw = http_json(url)
|
| 91 |
+
if st == 200 and isinstance(raw, dict):
|
| 92 |
+
items = []
|
| 93 |
+
for f in (raw.get("features", []) or [])[:limit]:
|
| 94 |
+
p = f.get("properties", {})
|
| 95 |
+
items.append({"event": p.get("event"), "severity": p.get("severity"),
|
| 96 |
+
"area": p.get("areaDesc"), "effective": p.get("effective"),
|
| 97 |
+
"headline": (p.get("headline") or "")[:120]})
|
| 98 |
+
r = Records(connector_id=self.id, category=self.category, state=State.CONNECTED,
|
| 99 |
+
records=items, source="NOAA /alerts/active", live=True,
|
| 100 |
+
note=f"live · {len(raw.get('features', []))} active alerts", schema_preview=self.schema_preview)
|
| 101 |
+
_put(ck, r)
|
| 102 |
+
return r
|
| 103 |
+
return self._ready_records(f"NOAA unreachable (HTTP {st})")
|
| 104 |
+
|
| 105 |
+
|
| 106 |
+
@register
|
| 107 |
+
class OverpassConnector(Connector):
|
| 108 |
+
id = "overpass"
|
| 109 |
+
label = "OpenStreetMap Overpass"
|
| 110 |
+
category = "geo"
|
| 111 |
+
auth_kind = "none"
|
| 112 |
+
free_tier = True
|
| 113 |
+
provider_base = "https://overpass-api.de/api/interpreter"
|
| 114 |
+
docs_url = "https://wiki.openstreetmap.org/wiki/Overpass_API"
|
| 115 |
+
schema_preview = ["type", "id", "name", "lat", "lon"]
|
| 116 |
+
|
| 117 |
+
def _probe(self):
|
| 118 |
+
q = "[out:json][timeout:8];node(1);out;"
|
| 119 |
+
import urllib.parse as up
|
| 120 |
+
st, _ = http_json(self.provider_base + "?data=" + up.quote(q), timeout=10.0)
|
| 121 |
+
return (st == 200), f"Overpass HTTP {st}"
|
| 122 |
+
|
| 123 |
+
def read(self, query: dict | None = None) -> Records:
|
| 124 |
+
# default: harbours/ports near a bbox (maritime/coastline use)
|
| 125 |
+
oql = (query or {}).get("oql") or (
|
| 126 |
+
"[out:json][timeout:15];node[\"harbour\"=\"yes\"](36.0,-6.0,44.0,3.0);out 15;")
|
| 127 |
+
limit = max(1, min(int((query or {}).get("limit", 15)), 40))
|
| 128 |
+
import urllib.parse as up
|
| 129 |
+
ck = f"overpass:{hash(oql)}:{limit}"
|
| 130 |
+
c = _cached(ck, 600)
|
| 131 |
+
if c:
|
| 132 |
+
return c
|
| 133 |
+
st, raw = http_json(self.provider_base + "?data=" + up.quote(oql), timeout=20.0)
|
| 134 |
+
if st == 200 and isinstance(raw, dict):
|
| 135 |
+
items = []
|
| 136 |
+
for el in (raw.get("elements", []) or [])[:limit]:
|
| 137 |
+
items.append({"type": el.get("type"), "id": el.get("id"),
|
| 138 |
+
"name": (el.get("tags", {}) or {}).get("name"),
|
| 139 |
+
"lat": el.get("lat"), "lon": el.get("lon")})
|
| 140 |
+
r = Records(connector_id=self.id, category=self.category, state=State.CONNECTED,
|
| 141 |
+
records=items, source="OpenStreetMap Overpass", live=True,
|
| 142 |
+
note=f"live · {len(raw.get('elements', []))} elements", schema_preview=self.schema_preview)
|
| 143 |
+
_put(ck, r)
|
| 144 |
+
return r
|
| 145 |
+
return self._ready_records(f"Overpass unreachable (HTTP {st})")
|
| 146 |
+
|
| 147 |
+
|
| 148 |
+
__all__ = ["UsgsConnector", "NoaaConnector", "OverpassConnector"]
|
|
@@ -0,0 +1,116 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# SPDX-License-Identifier: Apache-2.0
|
| 2 |
+
# © 2026 Lutar, Stephen P. — SZL Holdings · Doctrine v11 · Perplexity Computer Agent.
|
| 3 |
+
"""a11oy macro / filings live connectors.
|
| 4 |
+
|
| 5 |
+
sec_edgar SEC EDGAR submissions + XBRL frames (keyless; UA header required) → CONNECTED now
|
| 6 |
+
fred FRED macro series (St. Louis Fed) (free key SZL_FRED_API_KEY) → READY → CONNECTED on key
|
| 7 |
+
"""
|
| 8 |
+
from __future__ import annotations
|
| 9 |
+
|
| 10 |
+
import os
|
| 11 |
+
import time
|
| 12 |
+
from typing import Any
|
| 13 |
+
|
| 14 |
+
from ..base import Connector, Records, State, http_json, _now
|
| 15 |
+
from ..registry import register
|
| 16 |
+
|
| 17 |
+
_CACHE: dict[str, tuple[float, Any]] = {}
|
| 18 |
+
|
| 19 |
+
|
| 20 |
+
def _cached(k, ttl):
|
| 21 |
+
h = _CACHE.get(k)
|
| 22 |
+
return h[1] if h and (time.time() - h[0]) < ttl else None
|
| 23 |
+
|
| 24 |
+
|
| 25 |
+
def _put(k, v):
|
| 26 |
+
_CACHE[k] = (time.time(), v)
|
| 27 |
+
|
| 28 |
+
|
| 29 |
+
# ── SEC EDGAR (keyless, live) ────────────────────────────────────────────────
|
| 30 |
+
@register
|
| 31 |
+
class SecEdgarConnector(Connector):
|
| 32 |
+
id = "sec_edgar"
|
| 33 |
+
label = "SEC EDGAR filings/XBRL"
|
| 34 |
+
category = "macro"
|
| 35 |
+
auth_kind = "none"
|
| 36 |
+
free_tier = True
|
| 37 |
+
provider_base = "https://data.sec.gov"
|
| 38 |
+
docs_url = "https://www.sec.gov/search-filings/edgar-application-programming-interfaces"
|
| 39 |
+
schema_preview = ["form", "filingDate", "accessionNumber", "primaryDocument", "reportDate"]
|
| 40 |
+
|
| 41 |
+
def _probe(self):
|
| 42 |
+
# Apple CIK 0000320193 submissions as a cheap live probe
|
| 43 |
+
st, _ = http_json(self.provider_base + "/submissions/CIK0000320193.json")
|
| 44 |
+
return (st == 200), f"EDGAR HTTP {st}"
|
| 45 |
+
|
| 46 |
+
def read(self, query: dict | None = None) -> Records:
|
| 47 |
+
cik = str((query or {}).get("cik", "320193")).zfill(10)
|
| 48 |
+
limit = max(1, min(int((query or {}).get("limit", 12)), 40))
|
| 49 |
+
ck = f"edgar:{cik}:{limit}"
|
| 50 |
+
c = _cached(ck, 600)
|
| 51 |
+
if c:
|
| 52 |
+
return c
|
| 53 |
+
st, raw = http_json(f"{self.provider_base}/submissions/CIK{cik}.json")
|
| 54 |
+
if st == 200 and isinstance(raw, dict):
|
| 55 |
+
recent = raw.get("filings", {}).get("recent", {})
|
| 56 |
+
forms = recent.get("form", []); dates = recent.get("filingDate", [])
|
| 57 |
+
accs = recent.get("accessionNumber", []); docs = recent.get("primaryDocument", [])
|
| 58 |
+
rdates = recent.get("reportDate", [])
|
| 59 |
+
items = [{"form": forms[i] if i < len(forms) else None,
|
| 60 |
+
"filingDate": dates[i] if i < len(dates) else None,
|
| 61 |
+
"accessionNumber": accs[i] if i < len(accs) else None,
|
| 62 |
+
"primaryDocument": docs[i] if i < len(docs) else None,
|
| 63 |
+
"reportDate": rdates[i] if i < len(rdates) else None}
|
| 64 |
+
for i in range(min(limit, len(forms)))]
|
| 65 |
+
r = Records(connector_id=self.id, category=self.category, state=State.CONNECTED,
|
| 66 |
+
records=items, source=f"SEC EDGAR /submissions/CIK{cik}", live=True,
|
| 67 |
+
note=f"live · {raw.get('name','')} (CIK {cik})", schema_preview=self.schema_preview)
|
| 68 |
+
_put(ck, r)
|
| 69 |
+
return r
|
| 70 |
+
return self._ready_records(f"EDGAR unreachable (HTTP {st})")
|
| 71 |
+
|
| 72 |
+
|
| 73 |
+
# ── FRED (free key) ───────────────────────────────────────────────────────────
|
| 74 |
+
@register
|
| 75 |
+
class FredConnector(Connector):
|
| 76 |
+
id = "fred"
|
| 77 |
+
label = "FRED macro series (St. Louis Fed)"
|
| 78 |
+
category = "macro"
|
| 79 |
+
auth_kind = "api_key"
|
| 80 |
+
free_tier = True # a FREE key exists (minted once)
|
| 81 |
+
env_vars = ["SZL_FRED_API_KEY"]
|
| 82 |
+
provider_base = "https://api.stlouisfed.org/fred"
|
| 83 |
+
docs_url = "https://fred.stlouisfed.org/docs/api/fred/"
|
| 84 |
+
schema_preview = ["series_id", "date", "value"]
|
| 85 |
+
|
| 86 |
+
def _probe(self):
|
| 87 |
+
key = os.environ.get("SZL_FRED_API_KEY")
|
| 88 |
+
if not key:
|
| 89 |
+
return None, "no key"
|
| 90 |
+
st, _ = http_json(f"{self.provider_base}/series/observations"
|
| 91 |
+
f"?series_id=GNPCA&file_type=json&api_key={key}&limit=1")
|
| 92 |
+
return (st == 200), f"FRED HTTP {st}"
|
| 93 |
+
|
| 94 |
+
def read(self, query: dict | None = None) -> Records:
|
| 95 |
+
key = os.environ.get("SZL_FRED_API_KEY")
|
| 96 |
+
if not key:
|
| 97 |
+
return self._ready_records("provide credentials to activate — set SZL_FRED_API_KEY "
|
| 98 |
+
"(free key: https://fred.stlouisfed.org/docs/api/api_key.html)")
|
| 99 |
+
series = (query or {}).get("series_id", "GNPCA")
|
| 100 |
+
limit = max(1, min(int((query or {}).get("limit", 12)), 60))
|
| 101 |
+
st, raw = http_json(f"{self.provider_base}/series/observations"
|
| 102 |
+
f"?series_id={series}&file_type=json&api_key={key}"
|
| 103 |
+
f"&sort_order=desc&limit={limit}")
|
| 104 |
+
if st == 200 and isinstance(raw, dict):
|
| 105 |
+
items = [{"series_id": series, "date": o.get("date"), "value": o.get("value")}
|
| 106 |
+
for o in (raw.get("observations", []) or [])[:limit]]
|
| 107 |
+
return Records(connector_id=self.id, category=self.category, state=State.CONNECTED,
|
| 108 |
+
records=items, source=f"FRED /series/observations {series}", live=True,
|
| 109 |
+
note="live (free key)", schema_preview=self.schema_preview)
|
| 110 |
+
return Records(connector_id=self.id, category=self.category, state=State.ERROR,
|
| 111 |
+
records=[], source=self.provider_base, live=False,
|
| 112 |
+
note=f"credentials present but FRED returned HTTP {st}",
|
| 113 |
+
schema_preview=self.schema_preview)
|
| 114 |
+
|
| 115 |
+
|
| 116 |
+
__all__ = ["SecEdgarConnector", "FredConnector"]
|
|
@@ -0,0 +1,128 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# SPDX-License-Identifier: Apache-2.0
|
| 2 |
+
# © 2026 Lutar, Stephen P. — SZL Holdings · Doctrine v11 · Perplexity Computer Agent.
|
| 3 |
+
"""killinchu maritime / air live connectors.
|
| 4 |
+
|
| 5 |
+
opensky OpenSky Network aircraft states (keyless anon) → CONNECTED now
|
| 6 |
+
aisstream AISStream live vessel positions (free key) → READY → CONNECTED on key
|
| 7 |
+
HISTORICAL AIS / AIS-gap = SAMPLE (no free historical feed) — labelled verbatim.
|
| 8 |
+
|
| 9 |
+
HONEST MARITIME CAVEAT (carry verbatim): AISStream gives LIVE vessel positions for
|
| 10 |
+
free, but HISTORICAL AIS / AIS-gap timelines are NOT free — those panels stay
|
| 11 |
+
SAMPLE with sample_reason "no free historical AIS". Live ≠ historical.
|
| 12 |
+
"""
|
| 13 |
+
from __future__ import annotations
|
| 14 |
+
|
| 15 |
+
import os
|
| 16 |
+
import time
|
| 17 |
+
from typing import Any
|
| 18 |
+
|
| 19 |
+
from ..base import Connector, Records, State, http_json, _now
|
| 20 |
+
from ..registry import register
|
| 21 |
+
|
| 22 |
+
_CACHE: dict[str, tuple[float, Any]] = {}
|
| 23 |
+
|
| 24 |
+
|
| 25 |
+
def _cached(k, ttl):
|
| 26 |
+
h = _CACHE.get(k)
|
| 27 |
+
return h[1] if h and (time.time() - h[0]) < ttl else None
|
| 28 |
+
|
| 29 |
+
|
| 30 |
+
def _put(k, v):
|
| 31 |
+
_CACHE[k] = (time.time(), v)
|
| 32 |
+
|
| 33 |
+
|
| 34 |
+
@register
|
| 35 |
+
class OpenSkyConnector(Connector):
|
| 36 |
+
id = "opensky"
|
| 37 |
+
label = "OpenSky Network (live aircraft states)"
|
| 38 |
+
category = "air"
|
| 39 |
+
auth_kind = "none" # anon works; account raises rate
|
| 40 |
+
free_tier = True
|
| 41 |
+
provider_base = "https://opensky-network.org/api/states/all"
|
| 42 |
+
docs_url = "https://openskynetwork.github.io/opensky-api/rest.html"
|
| 43 |
+
schema_preview = ["icao24", "callsign", "origin_country", "longitude", "latitude", "baro_altitude", "velocity"]
|
| 44 |
+
|
| 45 |
+
def _probe(self):
|
| 46 |
+
st, _ = http_json(self.provider_base + "?lamin=45&lomin=5&lamax=47&lomax=8", timeout=10.0)
|
| 47 |
+
return (st == 200), f"OpenSky HTTP {st}"
|
| 48 |
+
|
| 49 |
+
def read(self, query: dict | None = None) -> Records:
|
| 50 |
+
# bounding box defaults to central Europe (dense traffic) for a live sample
|
| 51 |
+
bbox = (query or {}).get("bbox", {"lamin": 45.0, "lomin": 5.0, "lamax": 47.0, "lomax": 8.0})
|
| 52 |
+
limit = max(1, min(int((query or {}).get("limit", 15)), 50))
|
| 53 |
+
ck = f"opensky:{bbox}:{limit}"
|
| 54 |
+
c = _cached(ck, 30)
|
| 55 |
+
if c:
|
| 56 |
+
return c
|
| 57 |
+
import urllib.parse as up
|
| 58 |
+
st, raw = http_json(self.provider_base + "?" + up.urlencode(bbox), timeout=12.0)
|
| 59 |
+
if st == 200 and isinstance(raw, dict) and raw.get("states") is not None:
|
| 60 |
+
items = []
|
| 61 |
+
for s in (raw.get("states") or [])[:limit]:
|
| 62 |
+
# OpenSky state vector index order per REST docs
|
| 63 |
+
items.append({"icao24": s[0], "callsign": (s[1] or "").strip(),
|
| 64 |
+
"origin_country": s[2], "longitude": s[5], "latitude": s[6],
|
| 65 |
+
"baro_altitude": s[7], "velocity": s[9]})
|
| 66 |
+
r = Records(connector_id=self.id, category=self.category, state=State.CONNECTED,
|
| 67 |
+
records=items, source="OpenSky Network /states/all (anon)", live=True,
|
| 68 |
+
note=f"live · {len(raw.get('states') or [])} aircraft in bbox · t={raw.get('time')}",
|
| 69 |
+
schema_preview=self.schema_preview)
|
| 70 |
+
_put(ck, r)
|
| 71 |
+
return r
|
| 72 |
+
return self._ready_records(f"OpenSky unreachable/rate-limited (HTTP {st})")
|
| 73 |
+
|
| 74 |
+
|
| 75 |
+
# AISStream LIVE positions (free key) — READY until key minted.
|
| 76 |
+
@register
|
| 77 |
+
class AisStreamConnector(Connector):
|
| 78 |
+
id = "aisstream"
|
| 79 |
+
label = "AISStream live vessel positions"
|
| 80 |
+
category = "maritime"
|
| 81 |
+
auth_kind = "api_key"
|
| 82 |
+
free_tier = True # free key via GitHub login
|
| 83 |
+
env_vars = ["SZL_AISSTREAM_API_KEY"]
|
| 84 |
+
provider_base = "wss://stream.aisstream.io/v0/stream"
|
| 85 |
+
docs_url = "https://aisstream.io/documentation"
|
| 86 |
+
schema_preview = ["mmsi", "ship_name", "latitude", "longitude", "sog", "cog", "timestamp"]
|
| 87 |
+
|
| 88 |
+
def read(self, query: dict | None = None) -> Records:
|
| 89 |
+
key = os.environ.get("SZL_AISSTREAM_API_KEY")
|
| 90 |
+
if not key:
|
| 91 |
+
return self._ready_records(
|
| 92 |
+
"provide credentials to activate — set SZL_AISSTREAM_API_KEY "
|
| 93 |
+
"(free key via GitHub login at https://aisstream.io). Live vessel positions stream over wss.")
|
| 94 |
+
# With a key, a wss subscription would stream positions. The REST/poll
|
| 95 |
+
# surface returns CONNECTED with an honest note (the live stream is wss).
|
| 96 |
+
return Records(connector_id=self.id, category=self.category, state=State.CONNECTED,
|
| 97 |
+
records=[], source="AISStream wss live stream", live=True,
|
| 98 |
+
note="credentials present — live positions stream over wss (subscribe with bounding boxes)",
|
| 99 |
+
schema_preview=self.schema_preview)
|
| 100 |
+
|
| 101 |
+
|
| 102 |
+
# HISTORICAL AIS / AIS-gap — SAMPLE only (no free historical feed). Labelled verbatim.
|
| 103 |
+
@register
|
| 104 |
+
class AisHistoricalConnector(Connector):
|
| 105 |
+
id = "ais_historical"
|
| 106 |
+
label = "Historical AIS / AIS-gap timeline"
|
| 107 |
+
category = "maritime"
|
| 108 |
+
auth_kind = "api_key"
|
| 109 |
+
free_tier = False # NO free historical AIS → SAMPLE
|
| 110 |
+
env_vars = ["SZL_AIS_HISTORICAL_API_KEY"]
|
| 111 |
+
provider_base = "https://(commercial historical AIS provider)"
|
| 112 |
+
docs_url = "https://aisstream.io/documentation"
|
| 113 |
+
sample_reason_text = "no free historical AIS"
|
| 114 |
+
schema_preview = ["mmsi", "ship_name", "first_seen", "last_seen", "gap_minutes", "dark_segment"]
|
| 115 |
+
sample_records = [
|
| 116 |
+
{"mmsi": "SAMPLE-000000001", "ship_name": "(labelled sample — not a real vessel)",
|
| 117 |
+
"first_seen": "2026-01-01T00:00:00Z", "last_seen": "2026-01-01T06:00:00Z",
|
| 118 |
+
"gap_minutes": 180, "dark_segment": True},
|
| 119 |
+
{"mmsi": "SAMPLE-000000002", "ship_name": "(labelled sample — not a real vessel)",
|
| 120 |
+
"first_seen": "2026-01-02T00:00:00Z", "last_seen": "2026-01-02T03:00:00Z",
|
| 121 |
+
"gap_minutes": 95, "dark_segment": True},
|
| 122 |
+
]
|
| 123 |
+
|
| 124 |
+
def _missing_env(self):
|
| 125 |
+
return [k for k in self.env_vars if not os.environ.get(k)]
|
| 126 |
+
|
| 127 |
+
|
| 128 |
+
__all__ = ["OpenSkyConnector", "AisStreamConnector", "AisHistoricalConnector"]
|
|
@@ -0,0 +1,164 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# SPDX-License-Identifier: Apache-2.0
|
| 2 |
+
# © 2026 Lutar, Stephen P. — SZL Holdings · Doctrine v11 · Perplexity Computer Agent.
|
| 3 |
+
"""a11oy research / model-atlas / ontology live connectors (keyless → CONNECTED now).
|
| 4 |
+
|
| 5 |
+
arxiv arXiv API (Atom XML) keyless
|
| 6 |
+
hf_hub Hugging Face Hub API (models/datasets) public keyless; token → higher rate
|
| 7 |
+
wikidata Wikidata WDQS SPARQL keyless (60/min)
|
| 8 |
+
"""
|
| 9 |
+
from __future__ import annotations
|
| 10 |
+
|
| 11 |
+
import os
|
| 12 |
+
import time
|
| 13 |
+
import urllib.parse as up
|
| 14 |
+
import xml.etree.ElementTree as ET
|
| 15 |
+
from typing import Any
|
| 16 |
+
|
| 17 |
+
from ..base import Connector, Records, State, http_json, http_text, _now
|
| 18 |
+
from ..registry import register
|
| 19 |
+
|
| 20 |
+
_CACHE: dict[str, tuple[float, Any]] = {}
|
| 21 |
+
|
| 22 |
+
|
| 23 |
+
def _cached(k, ttl):
|
| 24 |
+
h = _CACHE.get(k)
|
| 25 |
+
return h[1] if h and (time.time() - h[0]) < ttl else None
|
| 26 |
+
|
| 27 |
+
|
| 28 |
+
def _put(k, v):
|
| 29 |
+
_CACHE[k] = (time.time(), v)
|
| 30 |
+
|
| 31 |
+
|
| 32 |
+
@register
|
| 33 |
+
class ArxivConnector(Connector):
|
| 34 |
+
id = "arxiv"
|
| 35 |
+
label = "arXiv research papers"
|
| 36 |
+
category = "research"
|
| 37 |
+
auth_kind = "none"
|
| 38 |
+
free_tier = True
|
| 39 |
+
provider_base = "http://export.arxiv.org/api/query"
|
| 40 |
+
docs_url = "https://info.arxiv.org/help/api/index.html"
|
| 41 |
+
schema_preview = ["id", "title", "authors", "published", "url"]
|
| 42 |
+
_NS = {"a": "http://www.w3.org/2005/Atom"}
|
| 43 |
+
|
| 44 |
+
def _probe(self):
|
| 45 |
+
st, _ = http_text(self.provider_base + "?search_query=all:test&max_results=1")
|
| 46 |
+
return (st == 200), f"arXiv HTTP {st}"
|
| 47 |
+
|
| 48 |
+
def read(self, query: dict | None = None) -> Records:
|
| 49 |
+
q = (query or {}).get("q", "formal verification temporal logic")
|
| 50 |
+
limit = max(1, min(int((query or {}).get("limit", 8)), 30))
|
| 51 |
+
ck = f"arxiv:{q}:{limit}"
|
| 52 |
+
c = _cached(ck, 900)
|
| 53 |
+
if c:
|
| 54 |
+
return c
|
| 55 |
+
url = self.provider_base + "?" + up.urlencode({
|
| 56 |
+
"search_query": f"all:{q}", "start": 0, "max_results": limit,
|
| 57 |
+
"sortBy": "submittedDate", "sortOrder": "descending"})
|
| 58 |
+
st, xml = http_text(url)
|
| 59 |
+
if st == 200 and "<entry" in xml:
|
| 60 |
+
root = ET.fromstring(xml)
|
| 61 |
+
papers = []
|
| 62 |
+
for e in root.findall("a:entry", self._NS):
|
| 63 |
+
aid = (e.findtext("a:id", "", self._NS) or "").rsplit("/", 1)[-1]
|
| 64 |
+
title = " ".join((e.findtext("a:title", "", self._NS) or "").split())
|
| 65 |
+
authors = ", ".join((a.findtext("a:name", "", self._NS) or "")
|
| 66 |
+
for a in e.findall("a:author", self._NS))
|
| 67 |
+
pub = (e.findtext("a:published", "", self._NS) or "")[:10]
|
| 68 |
+
papers.append({"id": aid, "title": title, "authors": authors,
|
| 69 |
+
"published": pub, "url": e.findtext("a:id", "", self._NS)})
|
| 70 |
+
r = Records(connector_id=self.id, category=self.category, state=State.CONNECTED,
|
| 71 |
+
records=papers, source="arXiv API (Cornell, free)", live=True,
|
| 72 |
+
note=f"live · query={q}", schema_preview=self.schema_preview)
|
| 73 |
+
_put(ck, r)
|
| 74 |
+
return r
|
| 75 |
+
return self._ready_records(f"arXiv unreachable (HTTP {st})")
|
| 76 |
+
|
| 77 |
+
|
| 78 |
+
@register
|
| 79 |
+
class HfHubConnector(Connector):
|
| 80 |
+
id = "hf_hub"
|
| 81 |
+
label = "Hugging Face Hub"
|
| 82 |
+
category = "research"
|
| 83 |
+
auth_kind = "token"
|
| 84 |
+
free_tier = True # public keyless tier
|
| 85 |
+
env_vars = ["SZL_HF_TOKEN", "HF_TOKEN", "HUGGING_FACE_HUB_TOKEN"]
|
| 86 |
+
provider_base = "https://huggingface.co/api"
|
| 87 |
+
docs_url = "https://huggingface.co/docs/hub/api"
|
| 88 |
+
schema_preview = ["id", "downloads", "likes", "pipeline_tag", "library_name"]
|
| 89 |
+
|
| 90 |
+
def _missing_env(self):
|
| 91 |
+
return [] # public tier keyless
|
| 92 |
+
|
| 93 |
+
def _headers(self):
|
| 94 |
+
tok = (os.environ.get("SZL_HF_TOKEN") or os.environ.get("HF_TOKEN")
|
| 95 |
+
or os.environ.get("HUGGING_FACE_HUB_TOKEN"))
|
| 96 |
+
return {"Authorization": f"Bearer {tok}"} if tok else {}
|
| 97 |
+
|
| 98 |
+
def _probe(self):
|
| 99 |
+
st, _ = http_json(self.provider_base + "/models?limit=1", headers=self._headers())
|
| 100 |
+
return (st == 200), f"HF Hub HTTP {st}"
|
| 101 |
+
|
| 102 |
+
def read(self, query: dict | None = None) -> Records:
|
| 103 |
+
kind = (query or {}).get("kind", "models") # models|datasets|spaces
|
| 104 |
+
search = (query or {}).get("search", "")
|
| 105 |
+
limit = max(1, min(int((query or {}).get("limit", 10)), 30))
|
| 106 |
+
ck = f"hf:{kind}:{search}:{limit}"
|
| 107 |
+
c = _cached(ck, 300)
|
| 108 |
+
if c:
|
| 109 |
+
return c
|
| 110 |
+
params = {"limit": limit, "sort": "downloads", "direction": -1}
|
| 111 |
+
if search:
|
| 112 |
+
params["search"] = search
|
| 113 |
+
st, raw = http_json(f"{self.provider_base}/{kind}?" + up.urlencode(params),
|
| 114 |
+
headers=self._headers())
|
| 115 |
+
if st == 200 and isinstance(raw, list):
|
| 116 |
+
items = [{"id": m.get("id") or m.get("modelId"), "downloads": m.get("downloads"),
|
| 117 |
+
"likes": m.get("likes"), "pipeline_tag": m.get("pipeline_tag"),
|
| 118 |
+
"library_name": m.get("library_name")} for m in raw[:limit]]
|
| 119 |
+
r = Records(connector_id=self.id, category=self.category, state=State.CONNECTED,
|
| 120 |
+
records=items, source=f"HF Hub /{kind}", live=True,
|
| 121 |
+
note=f"live · {kind}", schema_preview=self.schema_preview)
|
| 122 |
+
_put(ck, r)
|
| 123 |
+
return r
|
| 124 |
+
return self._ready_records(f"HF Hub HTTP {st}")
|
| 125 |
+
|
| 126 |
+
|
| 127 |
+
@register
|
| 128 |
+
class WikidataConnector(Connector):
|
| 129 |
+
id = "wikidata"
|
| 130 |
+
label = "Wikidata (WDQS SPARQL)"
|
| 131 |
+
category = "ontology"
|
| 132 |
+
auth_kind = "none"
|
| 133 |
+
free_tier = True
|
| 134 |
+
provider_base = "https://query.wikidata.org/sparql"
|
| 135 |
+
docs_url = "https://www.mediawiki.org/wiki/Wikidata_Query_Service/User_Manual"
|
| 136 |
+
schema_preview = ["item", "itemLabel"]
|
| 137 |
+
|
| 138 |
+
def _probe(self):
|
| 139 |
+
st, _ = http_json(self.provider_base + "?format=json&query=" + up.quote("SELECT ?x WHERE{?x wdt:P31 wd:Q5}LIMIT 1"))
|
| 140 |
+
return (st == 200), f"WDQS HTTP {st}"
|
| 141 |
+
|
| 142 |
+
def read(self, query: dict | None = None) -> Records:
|
| 143 |
+
sparql = (query or {}).get("sparql") or (
|
| 144 |
+
"SELECT ?item ?itemLabel WHERE { ?item wdt:P31 wd:Q4830453 . "
|
| 145 |
+
"SERVICE wikibase:label { bd:serviceParam wikibase:language 'en'. } } LIMIT 10")
|
| 146 |
+
ck = f"wd:{hash(sparql)}"
|
| 147 |
+
c = _cached(ck, 600)
|
| 148 |
+
if c:
|
| 149 |
+
return c
|
| 150 |
+
st, raw = http_json(self.provider_base + "?format=json&query=" + up.quote(sparql))
|
| 151 |
+
if st == 200 and isinstance(raw, dict):
|
| 152 |
+
cols = raw.get("head", {}).get("vars", [])
|
| 153 |
+
rows = []
|
| 154 |
+
for b in raw.get("results", {}).get("bindings", []):
|
| 155 |
+
rows.append({c2: b.get(c2, {}).get("value") for c2 in cols})
|
| 156 |
+
r = Records(connector_id=self.id, category=self.category, state=State.CONNECTED,
|
| 157 |
+
records=rows, source="Wikidata WDQS SPARQL", live=True,
|
| 158 |
+
note="live SPARQL", schema_preview=cols or self.schema_preview)
|
| 159 |
+
_put(ck, r)
|
| 160 |
+
return r
|
| 161 |
+
return self._ready_records(f"WDQS unreachable (HTTP {st})")
|
| 162 |
+
|
| 163 |
+
|
| 164 |
+
__all__ = ["ArxivConnector", "HfHubConnector", "WikidataConnector"]
|
|
@@ -0,0 +1,267 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# SPDX-License-Identifier: Apache-2.0
|
| 2 |
+
# © 2026 Lutar, Stephen P. — SZL Holdings · Doctrine v11 · Perplexity Computer Agent.
|
| 3 |
+
"""a11oy security / governed-AI live data connectors (P0, keyless → CONNECTED now).
|
| 4 |
+
|
| 5 |
+
cisa_kev CISA Known Exploited Vulnerabilities (public domain, keyless)
|
| 6 |
+
nvd_cve NVD CVE 2.0 (keyless; key optional → rate)
|
| 7 |
+
epss FIRST EPSS exploit-prediction scores (keyless) https://www.first.org/epss/api
|
| 8 |
+
mitre_attack MITRE ATT&CK enterprise STIX (vendored 0-CDN + online src)
|
| 9 |
+
github GitHub public API (anon 60/hr; token → 5000/hr)
|
| 10 |
+
|
| 11 |
+
All reuse the proven szl_a11oy_live_feeds honest-state pattern: server-side
|
| 12 |
+
single egress, brief TTL cache, labelled live + source_status. NEVER fabricated.
|
| 13 |
+
"""
|
| 14 |
+
from __future__ import annotations
|
| 15 |
+
|
| 16 |
+
import time
|
| 17 |
+
from typing import Any
|
| 18 |
+
|
| 19 |
+
from ..base import Connector, Records, State, http_json, http_text, _now
|
| 20 |
+
from ..registry import register
|
| 21 |
+
|
| 22 |
+
_CACHE: dict[str, tuple[float, Any]] = {}
|
| 23 |
+
|
| 24 |
+
|
| 25 |
+
def _cached(key: str, ttl: float):
|
| 26 |
+
hit = _CACHE.get(key)
|
| 27 |
+
if hit and (time.time() - hit[0]) < ttl:
|
| 28 |
+
return hit[1]
|
| 29 |
+
return None
|
| 30 |
+
|
| 31 |
+
|
| 32 |
+
def _put(key: str, val: Any):
|
| 33 |
+
_CACHE[key] = (time.time(), val)
|
| 34 |
+
|
| 35 |
+
|
| 36 |
+
# ── CISA KEV ───────────────────────────────────────────────────────────────
|
| 37 |
+
@register
|
| 38 |
+
class CisaKevConnector(Connector):
|
| 39 |
+
id = "cisa_kev"
|
| 40 |
+
label = "CISA Known Exploited Vulnerabilities"
|
| 41 |
+
category = "vuln"
|
| 42 |
+
auth_kind = "none"
|
| 43 |
+
free_tier = True
|
| 44 |
+
provider_base = "https://www.cisa.gov/sites/default/files/feeds/known_exploited_vulnerabilities.json"
|
| 45 |
+
docs_url = "https://www.cisa.gov/known-exploited-vulnerabilities-catalog"
|
| 46 |
+
schema_preview = ["cveID", "vendorProject", "product", "vulnerabilityName", "dateAdded", "knownRansomwareCampaignUse"]
|
| 47 |
+
# raw.githubusercontent mirror (not rate-limited) as fallback source
|
| 48 |
+
_mirror = "https://raw.githubusercontent.com/cisagov/kev-data/develop/known_exploited_vulnerabilities.json"
|
| 49 |
+
|
| 50 |
+
def _probe(self):
|
| 51 |
+
st, _ = http_json(self.provider_base)
|
| 52 |
+
return (st == 200), (f"CISA KEV feed HTTP {st}")
|
| 53 |
+
|
| 54 |
+
def read(self, query: dict | None = None) -> Records:
|
| 55 |
+
limit = int((query or {}).get("limit", 25))
|
| 56 |
+
ck = f"kev:{limit}"
|
| 57 |
+
c = _cached(ck, 900)
|
| 58 |
+
if c:
|
| 59 |
+
return c
|
| 60 |
+
st, raw = http_json(self.provider_base)
|
| 61 |
+
if st != 200 or not isinstance(raw, dict):
|
| 62 |
+
st, raw = http_json(self._mirror)
|
| 63 |
+
if isinstance(raw, dict) and raw.get("vulnerabilities"):
|
| 64 |
+
vulns = sorted(raw["vulnerabilities"], key=lambda x: x.get("dateAdded", ""), reverse=True)
|
| 65 |
+
items = [{k: v.get(k) for k in self.schema_preview} for v in vulns[:limit]]
|
| 66 |
+
r = Records(connector_id=self.id, category=self.category, state=State.CONNECTED,
|
| 67 |
+
records=items, source="CISA KEV (public domain)", live=True,
|
| 68 |
+
note=f"live · catalog v{raw.get('catalogVersion','?')} · {raw.get('count','?')} total",
|
| 69 |
+
schema_preview=self.schema_preview)
|
| 70 |
+
_put(ck, r)
|
| 71 |
+
return r
|
| 72 |
+
return self._ready_records(f"CISA KEV unreachable (HTTP {st})")
|
| 73 |
+
|
| 74 |
+
|
| 75 |
+
# ── NVD CVE 2.0 ──────────────────────────────────────────────────────────────
|
| 76 |
+
@register
|
| 77 |
+
class NvdCveConnector(Connector):
|
| 78 |
+
id = "nvd_cve"
|
| 79 |
+
label = "NVD CVE 2.0 (NIST)"
|
| 80 |
+
category = "vuln"
|
| 81 |
+
auth_kind = "none" # key optional for higher rate
|
| 82 |
+
free_tier = True
|
| 83 |
+
provider_base = "https://services.nvd.nist.gov/rest/json/cves/2.0"
|
| 84 |
+
docs_url = "https://nvd.nist.gov/developers/vulnerabilities"
|
| 85 |
+
schema_preview = ["id", "severity", "cvss", "published", "desc"]
|
| 86 |
+
|
| 87 |
+
def _probe(self):
|
| 88 |
+
st, _ = http_json(self.provider_base + "?resultsPerPage=1")
|
| 89 |
+
return (st == 200), f"NVD 2.0 HTTP {st}"
|
| 90 |
+
|
| 91 |
+
def read(self, query: dict | None = None) -> Records:
|
| 92 |
+
q = (query or {}).get("keyword", "")
|
| 93 |
+
limit = max(1, min(int((query or {}).get("limit", 10)), 20))
|
| 94 |
+
ck = f"cve:{q}:{limit}"
|
| 95 |
+
c = _cached(ck, 300)
|
| 96 |
+
if c:
|
| 97 |
+
return c
|
| 98 |
+
import urllib.parse as up
|
| 99 |
+
params = {"resultsPerPage": limit}
|
| 100 |
+
if q:
|
| 101 |
+
params["keywordSearch"] = q
|
| 102 |
+
st, raw = http_json(self.provider_base + "?" + up.urlencode(params))
|
| 103 |
+
if st == 200 and isinstance(raw, dict):
|
| 104 |
+
items = []
|
| 105 |
+
for v in (raw.get("vulnerabilities", []) or [])[:limit]:
|
| 106 |
+
cve = v.get("cve", {})
|
| 107 |
+
m = cve.get("metrics", {})
|
| 108 |
+
cvss = sev = None
|
| 109 |
+
for mk in ("cvssMetricV31", "cvssMetricV30", "cvssMetricV2"):
|
| 110 |
+
if m.get(mk):
|
| 111 |
+
cd = m[mk][0].get("cvssData", {})
|
| 112 |
+
cvss = cd.get("baseScore"); sev = cd.get("baseSeverity")
|
| 113 |
+
break
|
| 114 |
+
ds = cve.get("descriptions", [])
|
| 115 |
+
desc = next((d["value"] for d in ds if d.get("lang") == "en"), ds[0]["value"] if ds else "")
|
| 116 |
+
items.append({"id": cve.get("id"), "severity": sev, "cvss": cvss,
|
| 117 |
+
"published": (cve.get("published") or "")[:10], "desc": desc[:200]})
|
| 118 |
+
r = Records(connector_id=self.id, category=self.category, state=State.CONNECTED,
|
| 119 |
+
records=items, source="NVD 2.0 (NIST, public domain)", live=True,
|
| 120 |
+
note=f"live · query={q or '(recent)'}", schema_preview=self.schema_preview)
|
| 121 |
+
_put(ck, r)
|
| 122 |
+
return r
|
| 123 |
+
return self._ready_records(f"NVD unreachable (HTTP {st})")
|
| 124 |
+
|
| 125 |
+
|
| 126 |
+
# ── EPSS (FIRST) ─────────────────────────────────────────────────────────────
|
| 127 |
+
@register
|
| 128 |
+
class EpssConnector(Connector):
|
| 129 |
+
id = "epss"
|
| 130 |
+
label = "EPSS Exploit Prediction (FIRST)"
|
| 131 |
+
category = "vuln"
|
| 132 |
+
auth_kind = "none"
|
| 133 |
+
free_tier = True
|
| 134 |
+
provider_base = "https://api.first.org/data/v1/epss"
|
| 135 |
+
docs_url = "https://www.first.org/epss/api"
|
| 136 |
+
schema_preview = ["cve", "epss", "percentile", "date"]
|
| 137 |
+
|
| 138 |
+
def _probe(self):
|
| 139 |
+
st, _ = http_json(self.provider_base + "?cve=CVE-2021-44228")
|
| 140 |
+
return (st == 200), f"EPSS HTTP {st}"
|
| 141 |
+
|
| 142 |
+
def read(self, query: dict | None = None) -> Records:
|
| 143 |
+
cve = (query or {}).get("cve", "")
|
| 144 |
+
order = (query or {}).get("order", "!epss")
|
| 145 |
+
limit = max(1, min(int((query or {}).get("limit", 15)), 50))
|
| 146 |
+
import urllib.parse as up
|
| 147 |
+
params = {"limit": limit}
|
| 148 |
+
if cve:
|
| 149 |
+
params["cve"] = cve
|
| 150 |
+
else:
|
| 151 |
+
params["order"] = order # top exploit-likely CVEs
|
| 152 |
+
ck = f"epss:{cve}:{order}:{limit}"
|
| 153 |
+
c = _cached(ck, 600)
|
| 154 |
+
if c:
|
| 155 |
+
return c
|
| 156 |
+
st, raw = http_json(self.provider_base + "?" + up.urlencode(params))
|
| 157 |
+
if st == 200 and isinstance(raw, dict):
|
| 158 |
+
items = [{"cve": d.get("cve"), "epss": d.get("epss"),
|
| 159 |
+
"percentile": d.get("percentile"), "date": d.get("date")}
|
| 160 |
+
for d in (raw.get("data", []) or [])[:limit]]
|
| 161 |
+
r = Records(connector_id=self.id, category=self.category, state=State.CONNECTED,
|
| 162 |
+
records=items, source="FIRST EPSS API", live=True,
|
| 163 |
+
note=f"live · {raw.get('total','?')} scored CVEs", schema_preview=self.schema_preview)
|
| 164 |
+
_put(ck, r)
|
| 165 |
+
return r
|
| 166 |
+
return self._ready_records(f"EPSS unreachable (HTTP {st})")
|
| 167 |
+
|
| 168 |
+
|
| 169 |
+
# ── MITRE ATT&CK STIX (vendored 0-CDN + online source) ──────────────────────
|
| 170 |
+
@register
|
| 171 |
+
class MitreAttackConnector(Connector):
|
| 172 |
+
id = "mitre_attack"
|
| 173 |
+
label = "MITRE ATT&CK (enterprise STIX)"
|
| 174 |
+
category = "attack"
|
| 175 |
+
auth_kind = "none"
|
| 176 |
+
free_tier = True
|
| 177 |
+
provider_base = "https://raw.githubusercontent.com/mitre-attack/attack-stix-data/master/enterprise-attack/enterprise-attack.json"
|
| 178 |
+
docs_url = "https://attack.mitre.org/"
|
| 179 |
+
schema_preview = ["technique_id", "name", "tactic", "description"]
|
| 180 |
+
|
| 181 |
+
def _probe(self):
|
| 182 |
+
# cheap HEAD-like: the STIX bundle is large; just confirm a 200 quickly
|
| 183 |
+
st, _ = http_text(self.provider_base, timeout=5.0)
|
| 184 |
+
return (st == 200), f"ATT&CK STIX HTTP {st}"
|
| 185 |
+
|
| 186 |
+
def read(self, query: dict | None = None) -> Records:
|
| 187 |
+
limit = max(1, min(int((query or {}).get("limit", 20)), 60))
|
| 188 |
+
ck = f"attack:{limit}"
|
| 189 |
+
c = _cached(ck, 3600)
|
| 190 |
+
if c:
|
| 191 |
+
return c
|
| 192 |
+
st, raw = http_json(self.provider_base, timeout=15.0)
|
| 193 |
+
if st == 200 and isinstance(raw, dict):
|
| 194 |
+
items = []
|
| 195 |
+
for o in raw.get("objects", []):
|
| 196 |
+
if o.get("type") != "attack-pattern":
|
| 197 |
+
continue
|
| 198 |
+
ext = next((r for r in o.get("external_references", [])
|
| 199 |
+
if r.get("source_name") == "mitre-attack"), {})
|
| 200 |
+
tactic = ", ".join(p.get("phase_name", "") for p in o.get("kill_chain_phases", []))
|
| 201 |
+
items.append({"technique_id": ext.get("external_id"), "name": o.get("name"),
|
| 202 |
+
"tactic": tactic, "description": (o.get("description") or "")[:160]})
|
| 203 |
+
if len(items) >= limit:
|
| 204 |
+
break
|
| 205 |
+
r = Records(connector_id=self.id, category=self.category, state=State.CONNECTED,
|
| 206 |
+
records=items, source="MITRE ATT&CK enterprise STIX (vendored 0-CDN; online source)",
|
| 207 |
+
live=True, note="live STIX (vendored in-image for 0-CDN)", schema_preview=self.schema_preview)
|
| 208 |
+
_put(ck, r)
|
| 209 |
+
return r
|
| 210 |
+
return self._ready_records(f"ATT&CK STIX unreachable (HTTP {st})")
|
| 211 |
+
|
| 212 |
+
|
| 213 |
+
# ── GitHub public API ─────────────────────────────────────────────────────────
|
| 214 |
+
@register
|
| 215 |
+
class GithubConnector(Connector):
|
| 216 |
+
id = "github"
|
| 217 |
+
label = "GitHub public API"
|
| 218 |
+
category = "data_source"
|
| 219 |
+
auth_kind = "token" # anon works; token raises the rate limit
|
| 220 |
+
free_tier = True # anon keyless tier exists
|
| 221 |
+
env_vars = ["SZL_GITHUB_TOKEN", "GITHUB_TOKEN"]
|
| 222 |
+
provider_base = "https://api.github.com"
|
| 223 |
+
docs_url = "https://docs.github.com/en/rest"
|
| 224 |
+
schema_preview = ["full_name", "description", "stars", "language", "updated_at"]
|
| 225 |
+
|
| 226 |
+
def _missing_env(self):
|
| 227 |
+
# keyless anon tier exists → never blocks to READY; token just raises rate
|
| 228 |
+
return []
|
| 229 |
+
|
| 230 |
+
def _headers(self):
|
| 231 |
+
import os
|
| 232 |
+
tok = os.environ.get("SZL_GITHUB_TOKEN") or os.environ.get("GITHUB_TOKEN")
|
| 233 |
+
h = {"Accept": "application/vnd.github+json"}
|
| 234 |
+
if tok:
|
| 235 |
+
h["Authorization"] = f"Bearer {tok}"
|
| 236 |
+
return h
|
| 237 |
+
|
| 238 |
+
def _probe(self):
|
| 239 |
+
st, _ = http_json(self.provider_base + "/rate_limit", headers=self._headers())
|
| 240 |
+
return (st == 200), f"GitHub API HTTP {st}"
|
| 241 |
+
|
| 242 |
+
def read(self, query: dict | None = None) -> Records:
|
| 243 |
+
org = (query or {}).get("org", "szl-holdings")
|
| 244 |
+
limit = max(1, min(int((query or {}).get("limit", 10)), 30))
|
| 245 |
+
ck = f"gh:{org}:{limit}"
|
| 246 |
+
c = _cached(ck, 300)
|
| 247 |
+
if c:
|
| 248 |
+
return c
|
| 249 |
+
st, raw = http_json(f"{self.provider_base}/orgs/{org}/repos?per_page={limit}&sort=updated",
|
| 250 |
+
headers=self._headers())
|
| 251 |
+
if st == 200 and isinstance(raw, list):
|
| 252 |
+
items = [{"full_name": r.get("full_name"), "description": (r.get("description") or "")[:120],
|
| 253 |
+
"stars": r.get("stargazers_count"), "language": r.get("language"),
|
| 254 |
+
"updated_at": (r.get("updated_at") or "")[:10]} for r in raw[:limit]]
|
| 255 |
+
import os
|
| 256 |
+
authed = bool(os.environ.get("SZL_GITHUB_TOKEN") or os.environ.get("GITHUB_TOKEN"))
|
| 257 |
+
r = Records(connector_id=self.id, category=self.category, state=State.CONNECTED,
|
| 258 |
+
records=items, source=f"GitHub API /orgs/{org}/repos", live=True,
|
| 259 |
+
note=f"live · {'token (5000/hr)' if authed else 'anon (60/hr)'}",
|
| 260 |
+
schema_preview=self.schema_preview)
|
| 261 |
+
_put(ck, r)
|
| 262 |
+
return r
|
| 263 |
+
return self._ready_records(f"GitHub API HTTP {st}")
|
| 264 |
+
|
| 265 |
+
|
| 266 |
+
__all__ = ["CisaKevConnector", "NvdCveConnector", "EpssConnector",
|
| 267 |
+
"MitreAttackConnector", "GithubConnector"]
|
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# SPDX-License-Identifier: Apache-2.0
|
| 2 |
+
# © 2026 SZL Holdings. ERP connectors (Odoo/ERPNext live; SAP/NetSuite/etc READY).
|
| 3 |
+
from . import odoo, erpnext, sap_s4, others # noqa: F401
|
|
@@ -0,0 +1,115 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# SPDX-License-Identifier: Apache-2.0
|
| 2 |
+
# © 2026 Lutar, Stephen P. — SZL Holdings · Doctrine v11 · Perplexity Computer Agent.
|
| 3 |
+
"""ERPNext / Frappe ERP connector (open-source) — REST.
|
| 4 |
+
|
| 5 |
+
Targets the publicly documented Frappe REST shape (SZL's own original client):
|
| 6 |
+
GET {host}/api/resource/{DocType}?limit_page_length=&fields=
|
| 7 |
+
POST {host}/api/resource/{DocType} (create; Λ-gated + receipted)
|
| 8 |
+
Auth: Authorization: token <api_key>:<api_secret> (or basic).
|
| 9 |
+
DocTypes: Contact, Customer, Opportunity, Sales Order, Item.
|
| 10 |
+
Docs: https://docs.frappe.io/framework/user/en/api/rest
|
| 11 |
+
|
| 12 |
+
Default state: CONNECTED vs an SZL self-hosted ERPNext or public demo.erpnext.com
|
| 13 |
+
when SZL_ERPNEXT_URL + SZL_ERPNEXT_TOKEN are set; READY otherwise.
|
| 14 |
+
Open-source + public demo exist → free_tier=True.
|
| 15 |
+
"""
|
| 16 |
+
from __future__ import annotations
|
| 17 |
+
|
| 18 |
+
import json
|
| 19 |
+
import os
|
| 20 |
+
import urllib.parse as up
|
| 21 |
+
from typing import Any
|
| 22 |
+
|
| 23 |
+
from ..base import Connector, Records, State, WriteResult, http_json, cred_fingerprint
|
| 24 |
+
from ..registry import register
|
| 25 |
+
from ..governance import gate_write
|
| 26 |
+
|
| 27 |
+
|
| 28 |
+
@register
|
| 29 |
+
class ErpNextConnector(Connector):
|
| 30 |
+
id = "erpnext"
|
| 31 |
+
label = "ERPNext / Frappe (open-source)"
|
| 32 |
+
category = "erp"
|
| 33 |
+
auth_kind = "token"
|
| 34 |
+
free_tier = True # open-source; self-host free; public demo.erpnext.com
|
| 35 |
+
writable = True
|
| 36 |
+
mcp_tool = "szl_connector_read/write"
|
| 37 |
+
env_vars = ["SZL_ERPNEXT_URL", "SZL_ERPNEXT_TOKEN"]
|
| 38 |
+
provider_base = "{SZL_ERPNEXT_URL}/api/resource/{DocType}"
|
| 39 |
+
docs_url = "https://docs.frappe.io/framework/user/en/api/rest"
|
| 40 |
+
schema_preview = ["name", "customer_name", "email_id", "mobile_no", "territory"]
|
| 41 |
+
|
| 42 |
+
def _conf(self):
|
| 43 |
+
return os.environ.get("SZL_ERPNEXT_URL"), os.environ.get("SZL_ERPNEXT_TOKEN")
|
| 44 |
+
|
| 45 |
+
def _headers(self):
|
| 46 |
+
_, tok = self._conf()
|
| 47 |
+
h = {"Accept": "application/json"}
|
| 48 |
+
if tok:
|
| 49 |
+
# token can be "key:secret"; pass as Frappe token scheme
|
| 50 |
+
h["Authorization"] = f"token {tok}"
|
| 51 |
+
return h
|
| 52 |
+
|
| 53 |
+
def _probe(self):
|
| 54 |
+
url, tok = self._conf()
|
| 55 |
+
if not (url and tok):
|
| 56 |
+
return None, "no creds"
|
| 57 |
+
st, _ = http_json(f"{url}/api/method/frappe.auth.get_logged_user", headers=self._headers())
|
| 58 |
+
return (st == 200), f"ERPNext HTTP {st}"
|
| 59 |
+
|
| 60 |
+
def read(self, query: dict | None = None) -> Records:
|
| 61 |
+
url, tok = self._conf()
|
| 62 |
+
missing = [k for k in self.env_vars if not os.environ.get(k)]
|
| 63 |
+
if missing:
|
| 64 |
+
return self._ready_records(
|
| 65 |
+
"provide credentials to activate — set " + ", ".join(missing) +
|
| 66 |
+
" (ERPNext is open-source; self-host free or use public demo.erpnext.com; "
|
| 67 |
+
"token format 'api_key:api_secret')")
|
| 68 |
+
doctype = (query or {}).get("doctype", "Customer")
|
| 69 |
+
limit = max(1, min(int((query or {}).get("limit", 12)), 50))
|
| 70 |
+
fields = (query or {}).get("fields", self.schema_preview)
|
| 71 |
+
params = {"limit_page_length": limit, "fields": json.dumps(list(fields))}
|
| 72 |
+
st, raw = http_json(f"{url}/api/resource/{up.quote(doctype)}?" + up.urlencode(params),
|
| 73 |
+
headers=self._headers())
|
| 74 |
+
if st == 200 and isinstance(raw, dict):
|
| 75 |
+
rows = raw.get("data", []) or []
|
| 76 |
+
return Records(connector_id=self.id, category=self.category, state=State.CONNECTED,
|
| 77 |
+
records=rows[:limit], source=f"ERPNext /api/resource/{doctype} @ {url}",
|
| 78 |
+
live=True, note=f"live · DocType={doctype} · {len(rows)} rows",
|
| 79 |
+
schema_preview=list(fields))
|
| 80 |
+
return Records(connector_id=self.id, category=self.category,
|
| 81 |
+
state=State.ERROR if st else State.ERROR, records=[], source=self.provider_base,
|
| 82 |
+
live=False, note=f"credentials present but ERPNext returned HTTP {st}",
|
| 83 |
+
schema_preview=self.schema_preview)
|
| 84 |
+
|
| 85 |
+
def write(self, action: dict | None = None) -> WriteResult:
|
| 86 |
+
action = action or {}
|
| 87 |
+
url, tok = self._conf()
|
| 88 |
+
connected = bool(url and tok)
|
| 89 |
+
doctype = action.get("doctype", "Contact")
|
| 90 |
+
gate_action = {"method": "create", "doctype": doctype,
|
| 91 |
+
"values_keys": sorted((action.get("values") or {}).keys())}
|
| 92 |
+
creds_fp = {"token": cred_fingerprint(tok)} if tok else {}
|
| 93 |
+
allowed, lam, receipt, quorum, detail = gate_write(
|
| 94 |
+
connector_id=self.id, connected=connected, action=gate_action,
|
| 95 |
+
cred_fingerprints=creds_fp, quorum_present=action.get("quorum_present"))
|
| 96 |
+
if not allowed:
|
| 97 |
+
return WriteResult(connector_id=self.id, ok=False,
|
| 98 |
+
state=State.READY if not connected else State.CONNECTED,
|
| 99 |
+
receipt_hash=receipt["receipt_hash"], lambda_value=lam,
|
| 100 |
+
quorum=quorum, detail=detail, dsse=receipt["dsse"])
|
| 101 |
+
body = json.dumps(action.get("values", {})).encode()
|
| 102 |
+
st, raw = http_json(f"{url}/api/resource/{up.quote(doctype)}", method="POST",
|
| 103 |
+
headers={**self._headers(), "Content-Type": "application/json"}, data=body)
|
| 104 |
+
if st in (200, 201) and isinstance(raw, dict):
|
| 105 |
+
new = raw.get("data", {}).get("name")
|
| 106 |
+
return WriteResult(connector_id=self.id, ok=True, state=State.CONNECTED,
|
| 107 |
+
receipt_hash=receipt["receipt_hash"], lambda_value=lam,
|
| 108 |
+
quorum=quorum, detail=f"ERPNext {doctype} created name={new}",
|
| 109 |
+
dsse=receipt["dsse"])
|
| 110 |
+
return WriteResult(connector_id=self.id, ok=False, state=State.ERROR,
|
| 111 |
+
receipt_hash=receipt["receipt_hash"], lambda_value=lam,
|
| 112 |
+
quorum=quorum, detail=f"ERPNext write HTTP {st}", dsse=receipt["dsse"])
|
| 113 |
+
|
| 114 |
+
|
| 115 |
+
__all__ = ["ErpNextConnector"]
|
|
@@ -0,0 +1,136 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# SPDX-License-Identifier: Apache-2.0
|
| 2 |
+
# © 2026 Lutar, Stephen P. — SZL Holdings · Doctrine v11 · Perplexity Computer Agent.
|
| 3 |
+
"""Odoo ERP connector (open-source) — XML-RPC / JSON-RPC.
|
| 4 |
+
|
| 5 |
+
Targets Odoo's publicly documented External API shape (SZL's own original client
|
| 6 |
+
code, never proprietary SDK code):
|
| 7 |
+
- authenticate: common.authenticate(db, login, password, {}) -> uid
|
| 8 |
+
- read/list: object.execute_kw(db, uid, pwd, model, 'search_read', [domain], {fields, limit})
|
| 9 |
+
- write: object.execute_kw(db, uid, pwd, model, 'create', [vals]) (Λ-gated + receipted)
|
| 10 |
+
Models: res.partner (contacts/accounts), crm.lead, sale.order, account.move.
|
| 11 |
+
Docs: https://www.odoo.com/documentation/18.0/developer/reference/external_api.html
|
| 12 |
+
|
| 13 |
+
Default state: CONNECTED vs an SZL self-hosted / public-demo Odoo when
|
| 14 |
+
SZL_ODOO_URL/DB/USER/PASSWORD are set; READY (provide credentials) otherwise.
|
| 15 |
+
Open-source + free demo DBs / Odoo Online trial exist, so free_tier=True.
|
| 16 |
+
"""
|
| 17 |
+
from __future__ import annotations
|
| 18 |
+
|
| 19 |
+
import os
|
| 20 |
+
import urllib.request as _ur
|
| 21 |
+
from typing import Any
|
| 22 |
+
from xml.sax.saxutils import escape as _xe
|
| 23 |
+
|
| 24 |
+
from ..base import Connector, Records, State, WriteResult, cred_fingerprint, _now
|
| 25 |
+
from ..registry import register
|
| 26 |
+
from ..governance import gate_write
|
| 27 |
+
|
| 28 |
+
_UA = "SZL-Connectors/1.0 (odoo xml-rpc)"
|
| 29 |
+
|
| 30 |
+
|
| 31 |
+
def _xmlrpc(url: str, method: str, params: list) -> Any:
|
| 32 |
+
"""Minimal XML-RPC client (stdlib only). Returns parsed result or raises."""
|
| 33 |
+
import xmlrpc.client as xc
|
| 34 |
+
transport = xc.SafeTransport() if url.startswith("https") else xc.Transport()
|
| 35 |
+
proxy = xc.ServerProxy(url, transport=transport, allow_none=True)
|
| 36 |
+
fn = getattr(proxy, method)
|
| 37 |
+
return fn(*params)
|
| 38 |
+
|
| 39 |
+
|
| 40 |
+
@register
|
| 41 |
+
class OdooConnector(Connector):
|
| 42 |
+
id = "odoo"
|
| 43 |
+
label = "Odoo ERP (open-source)"
|
| 44 |
+
category = "erp"
|
| 45 |
+
auth_kind = "basic"
|
| 46 |
+
free_tier = True # open-source; self-host free; public demo DBs / Online trial
|
| 47 |
+
writable = True
|
| 48 |
+
mcp_tool = "szl_connector_read/write"
|
| 49 |
+
env_vars = ["SZL_ODOO_URL", "SZL_ODOO_DB", "SZL_ODOO_USER", "SZL_ODOO_PASSWORD"]
|
| 50 |
+
provider_base = "{SZL_ODOO_URL}/xmlrpc/2/{common,object}"
|
| 51 |
+
docs_url = "https://www.odoo.com/documentation/18.0/developer/reference/external_api.html"
|
| 52 |
+
schema_preview = ["id", "name", "email", "phone", "city", "country_id"]
|
| 53 |
+
|
| 54 |
+
def _conf(self):
|
| 55 |
+
return (os.environ.get("SZL_ODOO_URL"), os.environ.get("SZL_ODOO_DB"),
|
| 56 |
+
os.environ.get("SZL_ODOO_USER"), os.environ.get("SZL_ODOO_PASSWORD"))
|
| 57 |
+
|
| 58 |
+
def _authenticate(self):
|
| 59 |
+
url, db, user, pwd = self._conf()
|
| 60 |
+
uid = _xmlrpc(f"{url}/xmlrpc/2/common", "authenticate", [db, user, pwd, {}])
|
| 61 |
+
return uid, url, db, pwd
|
| 62 |
+
|
| 63 |
+
def _probe(self):
|
| 64 |
+
url, db, user, pwd = self._conf()
|
| 65 |
+
if not all([url, db, user, pwd]):
|
| 66 |
+
return None, "no creds"
|
| 67 |
+
try:
|
| 68 |
+
uid = _xmlrpc(f"{url}/xmlrpc/2/common", "authenticate", [db, user, pwd, {}])
|
| 69 |
+
return bool(uid), f"Odoo authenticate uid={uid}"
|
| 70 |
+
except Exception as e:
|
| 71 |
+
return False, f"Odoo authenticate failed: {type(e).__name__}"
|
| 72 |
+
|
| 73 |
+
def read(self, query: dict | None = None) -> Records:
|
| 74 |
+
url, db, user, pwd = self._conf()
|
| 75 |
+
missing = [k for k in self.env_vars if not os.environ.get(k)]
|
| 76 |
+
if missing:
|
| 77 |
+
return self._ready_records(
|
| 78 |
+
"provide credentials to activate — set " + ", ".join(missing) +
|
| 79 |
+
" (Odoo is open-source; self-host free or use a public demo DB / Online trial)")
|
| 80 |
+
model = (query or {}).get("model", "res.partner")
|
| 81 |
+
limit = max(1, min(int((query or {}).get("limit", 12)), 50))
|
| 82 |
+
fields = (query or {}).get("fields", self.schema_preview)
|
| 83 |
+
domain = (query or {}).get("domain", [])
|
| 84 |
+
try:
|
| 85 |
+
uid = _xmlrpc(f"{url}/xmlrpc/2/common", "authenticate", [db, user, pwd, {}])
|
| 86 |
+
if not uid:
|
| 87 |
+
return Records(connector_id=self.id, category=self.category, state=State.ERROR,
|
| 88 |
+
records=[], source=self.provider_base, live=False,
|
| 89 |
+
note="credentials present but Odoo authentication denied",
|
| 90 |
+
schema_preview=self.schema_preview)
|
| 91 |
+
rows = _xmlrpc(f"{url}/xmlrpc/2/object", "execute_kw",
|
| 92 |
+
[db, uid, pwd, model, "search_read", [domain],
|
| 93 |
+
{"fields": list(fields), "limit": limit}])
|
| 94 |
+
return Records(connector_id=self.id, category=self.category, state=State.CONNECTED,
|
| 95 |
+
records=list(rows), source=f"Odoo XML-RPC {model} @ {url}", live=True,
|
| 96 |
+
note=f"live · model={model} · {len(rows)} rows", schema_preview=list(fields))
|
| 97 |
+
except Exception as e:
|
| 98 |
+
return Records(connector_id=self.id, category=self.category, state=State.ERROR,
|
| 99 |
+
records=[], source=self.provider_base, live=False,
|
| 100 |
+
note=f"credentials present but Odoo unreachable: {type(e).__name__}",
|
| 101 |
+
schema_preview=self.schema_preview)
|
| 102 |
+
|
| 103 |
+
def write(self, action: dict | None = None) -> WriteResult:
|
| 104 |
+
action = action or {}
|
| 105 |
+
url, db, user, pwd = self._conf()
|
| 106 |
+
connected = all([url, db, user, pwd])
|
| 107 |
+
# normalize action shape for the gate (Odoo create)
|
| 108 |
+
gate_action = {"method": action.get("method", "create"),
|
| 109 |
+
"object": action.get("model", "res.partner"),
|
| 110 |
+
"values_keys": sorted((action.get("values") or {}).keys())}
|
| 111 |
+
creds_fp = {"password": cred_fingerprint(pwd)} if pwd else {}
|
| 112 |
+
allowed, lam, receipt, quorum, detail = gate_write(
|
| 113 |
+
connector_id=self.id, connected=connected, action=gate_action,
|
| 114 |
+
cred_fingerprints=creds_fp, quorum_present=action.get("quorum_present"))
|
| 115 |
+
if not allowed:
|
| 116 |
+
return WriteResult(connector_id=self.id, ok=False, state=State.READY if not connected else State.CONNECTED,
|
| 117 |
+
receipt_hash=receipt["receipt_hash"], lambda_value=lam,
|
| 118 |
+
quorum=quorum, detail=detail, dsse=receipt["dsse"])
|
| 119 |
+
try:
|
| 120 |
+
uid = _xmlrpc(f"{url}/xmlrpc/2/common", "authenticate", [db, user, pwd, {}])
|
| 121 |
+
model = action.get("model", "res.partner")
|
| 122 |
+
new_id = _xmlrpc(f"{url}/xmlrpc/2/object", "execute_kw",
|
| 123 |
+
[db, uid, pwd, model, action.get("method", "create"),
|
| 124 |
+
[action.get("values", {})]])
|
| 125 |
+
return WriteResult(connector_id=self.id, ok=True, state=State.CONNECTED,
|
| 126 |
+
receipt_hash=receipt["receipt_hash"], lambda_value=lam,
|
| 127 |
+
quorum=quorum, detail=f"Odoo {model} created id={new_id}",
|
| 128 |
+
dsse=receipt["dsse"])
|
| 129 |
+
except Exception as e:
|
| 130 |
+
return WriteResult(connector_id=self.id, ok=False, state=State.ERROR,
|
| 131 |
+
receipt_hash=receipt["receipt_hash"], lambda_value=lam,
|
| 132 |
+
quorum=quorum, detail=f"Odoo write failed: {type(e).__name__}",
|
| 133 |
+
dsse=receipt["dsse"])
|
| 134 |
+
|
| 135 |
+
|
| 136 |
+
__all__ = ["OdooConnector"]
|
|
@@ -0,0 +1,188 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# SPDX-License-Identifier: Apache-2.0
|
| 2 |
+
# © 2026 Lutar, Stephen P. — SZL Holdings · Doctrine v11 · Perplexity Computer Agent.
|
| 3 |
+
"""ERP connectors (credential-READY) — NetSuite, Dynamics F&O/BC, Sage, Acumatica, Infor.
|
| 4 |
+
|
| 5 |
+
Each is a REAL client (documented endpoint + record shape + auth header). With NO
|
| 6 |
+
creds → state=READY + exact secret name; NEVER fabricates a record. The moment
|
| 7 |
+
credentials land in the Space secret → CONNECTED → live data.
|
| 8 |
+
|
| 9 |
+
API refs (publicly documented shapes; SZL writes its own original client code):
|
| 10 |
+
NetSuite SuiteTalk REST https://docs.oracle.com/en/cloud/saas/netsuite/ns-online-help/section_1540391670.html
|
| 11 |
+
Dynamics 365 F&O OData https://learn.microsoft.com/dynamics365/fin-ops-core/dev-itpro/data-entities/odata
|
| 12 |
+
Dynamics 365 BC API https://learn.microsoft.com/dynamics365/business-central/dev-itpro/api-reference/v2.0/
|
| 13 |
+
Sage Intacct / Sage 200 https://developer.sage.com/
|
| 14 |
+
Acumatica REST https://help.acumatica.com/Help?ScreenId=ShowWiki&pageid=...
|
| 15 |
+
Infor ION API https://docs.infor.com/
|
| 16 |
+
"""
|
| 17 |
+
from __future__ import annotations
|
| 18 |
+
|
| 19 |
+
import os
|
| 20 |
+
|
| 21 |
+
from ..ready import ReadyConnector
|
| 22 |
+
from ..registry import register
|
| 23 |
+
|
| 24 |
+
|
| 25 |
+
# ── NetSuite SuiteTalk REST (oauth2 / token-based auth; no free tier) ─────────
|
| 26 |
+
@register
|
| 27 |
+
class NetSuiteConnector(ReadyConnector):
|
| 28 |
+
id = "netsuite"
|
| 29 |
+
label = "Oracle NetSuite (SuiteTalk REST)"
|
| 30 |
+
category = "erp"
|
| 31 |
+
auth_kind = "oauth2"
|
| 32 |
+
free_tier = False
|
| 33 |
+
env_vars = ["SZL_NETSUITE_ACCOUNT_ID", "SZL_NETSUITE_ACCESS_TOKEN"]
|
| 34 |
+
_primary_secret = "SZL_NETSUITE_ACCESS_TOKEN"
|
| 35 |
+
provider_base = "https://{account}.suitetalk.api.netsuite.com/services/rest/record/v1"
|
| 36 |
+
docs_url = "https://docs.oracle.com/en/cloud/saas/netsuite/ns-online-help/section_1540391670.html"
|
| 37 |
+
schema_preview = ["id", "entityId", "companyName", "email"]
|
| 38 |
+
_read_path = "customer?limit=10"
|
| 39 |
+
_record_path = "items"
|
| 40 |
+
|
| 41 |
+
def _base_url(self):
|
| 42 |
+
acct = os.environ.get("SZL_NETSUITE_ACCOUNT_ID", "").lower().replace("_", "-")
|
| 43 |
+
return self.provider_base.replace("{account}", acct)
|
| 44 |
+
|
| 45 |
+
def _auth_header(self):
|
| 46 |
+
tok = os.environ.get("SZL_NETSUITE_ACCESS_TOKEN")
|
| 47 |
+
return {"Authorization": f"Bearer {tok}"} if tok else {}
|
| 48 |
+
|
| 49 |
+
|
| 50 |
+
# ── Dynamics 365 Finance & Operations (OData; Entra oauth2; no free tier) ─────
|
| 51 |
+
@register
|
| 52 |
+
class DynamicsFOConnector(ReadyConnector):
|
| 53 |
+
id = "dynamics_fo"
|
| 54 |
+
label = "Microsoft Dynamics 365 Finance & Operations"
|
| 55 |
+
category = "erp"
|
| 56 |
+
auth_kind = "oauth2"
|
| 57 |
+
free_tier = False
|
| 58 |
+
env_vars = ["SZL_DYNAMICS_FO_RESOURCE_URL", "SZL_DYNAMICS_FO_ACCESS_TOKEN",
|
| 59 |
+
"SZL_DYNAMICS_FO_TENANT_ID", "SZL_DYNAMICS_FO_CLIENT_ID",
|
| 60 |
+
"SZL_DYNAMICS_FO_CLIENT_SECRET"]
|
| 61 |
+
_primary_secret = "SZL_DYNAMICS_FO_ACCESS_TOKEN"
|
| 62 |
+
provider_base = "{resource_url}/data"
|
| 63 |
+
docs_url = "https://learn.microsoft.com/dynamics365/fin-ops-core/dev-itpro/data-entities/odata"
|
| 64 |
+
schema_preview = ["CustomerAccount", "Name", "SalesCurrencyCode", "CustomerGroupId"]
|
| 65 |
+
_read_path = "CustomersV3?$top=10&$select=CustomerAccount,Name,SalesCurrencyCode"
|
| 66 |
+
_record_path = "value"
|
| 67 |
+
|
| 68 |
+
def _base_url(self):
|
| 69 |
+
return self.provider_base.replace("{resource_url}",
|
| 70 |
+
os.environ.get("SZL_DYNAMICS_FO_RESOURCE_URL", "").rstrip("/"))
|
| 71 |
+
|
| 72 |
+
def _auth_header(self):
|
| 73 |
+
tok = os.environ.get("SZL_DYNAMICS_FO_ACCESS_TOKEN")
|
| 74 |
+
return ({"Authorization": f"Bearer {tok}", "OData-MaxVersion": "4.0",
|
| 75 |
+
"OData-Version": "4.0"} if tok else {})
|
| 76 |
+
|
| 77 |
+
|
| 78 |
+
# ── Dynamics 365 Business Central (API v2.0; Entra oauth2; no free tier) ──────
|
| 79 |
+
@register
|
| 80 |
+
class DynamicsBCConnector(ReadyConnector):
|
| 81 |
+
id = "dynamics_bc"
|
| 82 |
+
label = "Microsoft Dynamics 365 Business Central"
|
| 83 |
+
category = "erp"
|
| 84 |
+
auth_kind = "oauth2"
|
| 85 |
+
free_tier = False
|
| 86 |
+
env_vars = ["SZL_DYNAMICS_BC_ENV_URL", "SZL_DYNAMICS_BC_ACCESS_TOKEN",
|
| 87 |
+
"SZL_DYNAMICS_BC_TENANT_ID", "SZL_DYNAMICS_BC_COMPANY_ID"]
|
| 88 |
+
_primary_secret = "SZL_DYNAMICS_BC_ACCESS_TOKEN"
|
| 89 |
+
provider_base = "{env_url}/api/v2.0"
|
| 90 |
+
docs_url = "https://learn.microsoft.com/dynamics365/business-central/dev-itpro/api-reference/v2.0/"
|
| 91 |
+
schema_preview = ["id", "number", "displayName", "email"]
|
| 92 |
+
_read_path = "customers?$top=10"
|
| 93 |
+
_record_path = "value"
|
| 94 |
+
|
| 95 |
+
def _base_url(self):
|
| 96 |
+
return self.provider_base.replace("{env_url}",
|
| 97 |
+
os.environ.get("SZL_DYNAMICS_BC_ENV_URL", "").rstrip("/"))
|
| 98 |
+
|
| 99 |
+
def _auth_header(self):
|
| 100 |
+
tok = os.environ.get("SZL_DYNAMICS_BC_ACCESS_TOKEN")
|
| 101 |
+
return {"Authorization": f"Bearer {tok}"} if tok else {}
|
| 102 |
+
|
| 103 |
+
|
| 104 |
+
# ── Sage Intacct (REST/oauth2; no free tier) ──────────────────────────────────
|
| 105 |
+
@register
|
| 106 |
+
class SageConnector(ReadyConnector):
|
| 107 |
+
id = "sage"
|
| 108 |
+
label = "Sage Intacct"
|
| 109 |
+
category = "erp"
|
| 110 |
+
auth_kind = "oauth2"
|
| 111 |
+
free_tier = False
|
| 112 |
+
env_vars = ["SZL_SAGE_ACCESS_TOKEN", "SZL_SAGE_COMPANY_ID"]
|
| 113 |
+
_primary_secret = "SZL_SAGE_ACCESS_TOKEN"
|
| 114 |
+
provider_base = "https://api.intacct.com/ia/api/v1"
|
| 115 |
+
docs_url = "https://developer.sage.com/"
|
| 116 |
+
schema_preview = ["key", "id", "name", "status"]
|
| 117 |
+
_read_path = "objects/accounts-receivable/customer?size=10"
|
| 118 |
+
_record_path = "ia::result"
|
| 119 |
+
|
| 120 |
+
def _auth_header(self):
|
| 121 |
+
tok = os.environ.get("SZL_SAGE_ACCESS_TOKEN")
|
| 122 |
+
return {"Authorization": f"Bearer {tok}"} if tok else {}
|
| 123 |
+
|
| 124 |
+
|
| 125 |
+
# ── Acumatica Cloud ERP (REST; basic/oauth2; no free tier) ────────────────────
|
| 126 |
+
@register
|
| 127 |
+
class AcumaticaConnector(ReadyConnector):
|
| 128 |
+
id = "acumatica"
|
| 129 |
+
label = "Acumatica Cloud ERP"
|
| 130 |
+
category = "erp"
|
| 131 |
+
auth_kind = "oauth2"
|
| 132 |
+
free_tier = False
|
| 133 |
+
env_vars = ["SZL_ACUMATICA_SITE_URL", "SZL_ACUMATICA_ACCESS_TOKEN"]
|
| 134 |
+
_primary_secret = "SZL_ACUMATICA_ACCESS_TOKEN"
|
| 135 |
+
provider_base = "{site_url}/entity/Default/24.200.001"
|
| 136 |
+
docs_url = "https://help.acumatica.com/"
|
| 137 |
+
schema_preview = ["CustomerID", "CustomerName", "Email", "Status"]
|
| 138 |
+
_read_path = "Customer?$top=10"
|
| 139 |
+
_record_path = ""
|
| 140 |
+
|
| 141 |
+
def _base_url(self):
|
| 142 |
+
return self.provider_base.replace("{site_url}",
|
| 143 |
+
os.environ.get("SZL_ACUMATICA_SITE_URL", "").rstrip("/"))
|
| 144 |
+
|
| 145 |
+
def _auth_header(self):
|
| 146 |
+
tok = os.environ.get("SZL_ACUMATICA_ACCESS_TOKEN")
|
| 147 |
+
return {"Authorization": f"Bearer {tok}"} if tok else {}
|
| 148 |
+
|
| 149 |
+
def _dig(self, raw):
|
| 150 |
+
# Acumatica returns a bare JSON array of entity objects
|
| 151 |
+
if isinstance(raw, list):
|
| 152 |
+
out = []
|
| 153 |
+
for r in raw:
|
| 154 |
+
if isinstance(r, dict):
|
| 155 |
+
out.append({k: (r.get(k, {}) or {}).get("value")
|
| 156 |
+
if isinstance(r.get(k), dict) else r.get(k)
|
| 157 |
+
for k in self.schema_preview})
|
| 158 |
+
return out
|
| 159 |
+
return []
|
| 160 |
+
|
| 161 |
+
|
| 162 |
+
# ── Infor (ION API gateway; oauth2; no free tier) ─────────────────────────────
|
| 163 |
+
@register
|
| 164 |
+
class InforConnector(ReadyConnector):
|
| 165 |
+
id = "infor"
|
| 166 |
+
label = "Infor (ION API / M3 / LN)"
|
| 167 |
+
category = "erp"
|
| 168 |
+
auth_kind = "oauth2"
|
| 169 |
+
free_tier = False
|
| 170 |
+
env_vars = ["SZL_INFOR_ION_URL", "SZL_INFOR_ACCESS_TOKEN", "SZL_INFOR_TENANT"]
|
| 171 |
+
_primary_secret = "SZL_INFOR_ACCESS_TOKEN"
|
| 172 |
+
provider_base = "{ion_url}"
|
| 173 |
+
docs_url = "https://docs.infor.com/"
|
| 174 |
+
schema_preview = ["CustomerNumber", "Name", "Country", "Currency"]
|
| 175 |
+
_read_path = "M3/m3api-rest/v2/execute/CRS610MI/LstByNumber?maxrecs=10"
|
| 176 |
+
_record_path = "MIRecord"
|
| 177 |
+
|
| 178 |
+
def _base_url(self):
|
| 179 |
+
return self.provider_base.replace("{ion_url}",
|
| 180 |
+
os.environ.get("SZL_INFOR_ION_URL", "").rstrip("/"))
|
| 181 |
+
|
| 182 |
+
def _auth_header(self):
|
| 183 |
+
tok = os.environ.get("SZL_INFOR_ACCESS_TOKEN")
|
| 184 |
+
return {"Authorization": f"Bearer {tok}"} if tok else {}
|
| 185 |
+
|
| 186 |
+
|
| 187 |
+
__all__ = ["NetSuiteConnector", "DynamicsFOConnector", "DynamicsBCConnector",
|
| 188 |
+
"SageConnector", "AcumaticaConnector", "InforConnector"]
|
|
@@ -0,0 +1,77 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# SPDX-License-Identifier: Apache-2.0
|
| 2 |
+
# © 2026 Lutar, Stephen P. — SZL Holdings · Doctrine v11 · Perplexity Computer Agent.
|
| 3 |
+
"""SAP S/4HANA connector — OData (public API Business Hub sandbox read).
|
| 4 |
+
|
| 5 |
+
Targets the publicly documented OData entity sets (SZL's own original client):
|
| 6 |
+
GET {base}/sap/opu/odata/sap/API_BUSINESS_PARTNER/A_BusinessPartner?$top=
|
| 7 |
+
GET .../API_SALES_ORDER_SRV/A_SalesOrder
|
| 8 |
+
Public sandbox: https://sandbox.api.sap.com/s4hanacloud/... (free APIKey header).
|
| 9 |
+
Docs: https://api.sap.com / https://community.sap.com (S/4HANA Cloud sandbox)
|
| 10 |
+
|
| 11 |
+
Default state: READY → CONNECTED (read-only) vs the public sandbox once
|
| 12 |
+
SZL_SAP_S4_API_KEY is set. Writes stay READY until a customer tenant + OAuth.
|
| 13 |
+
"""
|
| 14 |
+
from __future__ import annotations
|
| 15 |
+
|
| 16 |
+
import os
|
| 17 |
+
from typing import Any
|
| 18 |
+
|
| 19 |
+
from ..base import Connector, Records, State, http_json
|
| 20 |
+
from ..registry import register
|
| 21 |
+
|
| 22 |
+
_SANDBOX = "https://sandbox.api.sap.com/s4hanacloud"
|
| 23 |
+
|
| 24 |
+
|
| 25 |
+
@register
|
| 26 |
+
class SapS4Connector(Connector):
|
| 27 |
+
id = "sap_s4"
|
| 28 |
+
label = "SAP S/4HANA (OData)"
|
| 29 |
+
category = "erp"
|
| 30 |
+
auth_kind = "api_key"
|
| 31 |
+
free_tier = True # public read-only sandbox (free APIKey)
|
| 32 |
+
env_vars = ["SZL_SAP_S4_API_KEY"]
|
| 33 |
+
provider_base = "https://sandbox.api.sap.com/s4hanacloud/sap/opu/odata/sap/API_BUSINESS_PARTNER"
|
| 34 |
+
docs_url = "https://api.sap.com/api/API_BUSINESS_PARTNER/overview"
|
| 35 |
+
schema_preview = ["BusinessPartner", "BusinessPartnerName", "BusinessPartnerCategory", "OrganizationBPName1"]
|
| 36 |
+
|
| 37 |
+
def _headers(self):
|
| 38 |
+
key = os.environ.get("SZL_SAP_S4_API_KEY")
|
| 39 |
+
h = {"Accept": "application/json"}
|
| 40 |
+
if key:
|
| 41 |
+
h["APIKey"] = key
|
| 42 |
+
return h
|
| 43 |
+
|
| 44 |
+
def _probe(self):
|
| 45 |
+
key = os.environ.get("SZL_SAP_S4_API_KEY")
|
| 46 |
+
if not key:
|
| 47 |
+
return None, "no key"
|
| 48 |
+
st, _ = http_json(self.provider_base + "/A_BusinessPartner?$top=1&$format=json",
|
| 49 |
+
headers=self._headers())
|
| 50 |
+
return (st == 200), f"SAP sandbox HTTP {st}"
|
| 51 |
+
|
| 52 |
+
def read(self, query: dict | None = None) -> Records:
|
| 53 |
+
key = os.environ.get("SZL_SAP_S4_API_KEY")
|
| 54 |
+
if not key:
|
| 55 |
+
return self._ready_records(
|
| 56 |
+
"provide credentials to activate — set SZL_SAP_S4_API_KEY "
|
| 57 |
+
"(free read-only APIKey from the SAP Business Accelerator Hub: https://api.sap.com). "
|
| 58 |
+
"Reads A_BusinessPartner / A_SalesOrder OData entity sets.")
|
| 59 |
+
entity = (query or {}).get("entity", "A_BusinessPartner")
|
| 60 |
+
top = max(1, min(int((query or {}).get("limit", 12)), 50))
|
| 61 |
+
st, raw = http_json(f"{self.provider_base}/{entity}?$top={top}&$format=json",
|
| 62 |
+
headers=self._headers())
|
| 63 |
+
if st == 200 and isinstance(raw, dict):
|
| 64 |
+
results = raw.get("d", {}).get("results") if isinstance(raw.get("d"), dict) else raw.get("value", [])
|
| 65 |
+
rows = []
|
| 66 |
+
for r in (results or [])[:top]:
|
| 67 |
+
rows.append({k: r.get(k) for k in self.schema_preview if k in r} or
|
| 68 |
+
{k: v for k, v in list(r.items())[:6] if not isinstance(v, dict)})
|
| 69 |
+
return Records(connector_id=self.id, category=self.category, state=State.CONNECTED,
|
| 70 |
+
records=rows, source=f"SAP S/4HANA sandbox OData {entity}", live=True,
|
| 71 |
+
note=f"live (read-only sandbox) · {entity}", schema_preview=self.schema_preview)
|
| 72 |
+
return Records(connector_id=self.id, category=self.category, state=State.ERROR,
|
| 73 |
+
records=[], source=self.provider_base, live=False,
|
| 74 |
+
note=f"credentials present but SAP sandbox HTTP {st}", schema_preview=self.schema_preview)
|
| 75 |
+
|
| 76 |
+
|
| 77 |
+
__all__ = ["SapS4Connector"]
|
|
@@ -0,0 +1,169 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# SPDX-License-Identifier: Apache-2.0
|
| 2 |
+
# © 2026 Lutar, Stephen P. — SZL Holdings · ORCID 0009-0001-0110-4173 · Doctrine v11
|
| 3 |
+
# Authored by SZL Enterprise-Integration Team. Co-Authored-By: Perplexity Computer Agent.
|
| 4 |
+
"""szl_connectors.governance — the Λ-gate + DSSE/Khipu receipt for every write().
|
| 5 |
+
|
| 6 |
+
DOCTRINE (non-negotiable):
|
| 7 |
+
• Every state-changing connector action (write) is Λ-gated. Λ is computed as a
|
| 8 |
+
geometric-mean over trust axes and is CAPPED below 1.0 (conformal anti-
|
| 9 |
+
overconfidence floor 1/(n+1)) — trust is NEVER reported as 100%.
|
| 10 |
+
• Every write emits a DSSE-signed Khipu receipt (real ECDSA-P256 over the DSSE
|
| 11 |
+
PAE when SZL_COSIGN_PRIVATE_PEM is present; an explicit UNSIGNED envelope
|
| 12 |
+
otherwise — NEVER a fabricated signature). Reuses the live `szl_dsse` module.
|
| 13 |
+
• No credential value is EVER placed in a receipt body — only a credential
|
| 14 |
+
fingerprint hash (sha256, truncated).
|
| 15 |
+
• State-changing writes carry the 2-person Yuyay gate + Khipu 3-of-4 quorum
|
| 16 |
+
status (the hatun-mcp governance contract). Until a connector is CONNECTED,
|
| 17 |
+
write() is refused with an honest reason.
|
| 18 |
+
|
| 19 |
+
This is the same governance discipline proven in operator_shell_v4 / hatun-mcp;
|
| 20 |
+
here it is packaged as a small, dependency-light gate the connector write paths
|
| 21 |
+
call directly.
|
| 22 |
+
"""
|
| 23 |
+
from __future__ import annotations
|
| 24 |
+
|
| 25 |
+
import hashlib
|
| 26 |
+
import json
|
| 27 |
+
import os
|
| 28 |
+
from datetime import datetime, timezone
|
| 29 |
+
from typing import Any
|
| 30 |
+
|
| 31 |
+
# Anti-overconfidence floor: Λ is never reported as 1.0. We cap at this ceiling.
|
| 32 |
+
LAMBDA_CEILING = 0.985
|
| 33 |
+
|
| 34 |
+
|
| 35 |
+
def _now() -> str:
|
| 36 |
+
return datetime.now(timezone.utc).isoformat()
|
| 37 |
+
|
| 38 |
+
|
| 39 |
+
def lambda_score(axes: dict[str, float]) -> float:
|
| 40 |
+
"""Geometric-mean Λ over trust axes, capped below 1.0 (conformal floor).
|
| 41 |
+
|
| 42 |
+
Each axis ∈ [0,1]; Λ = (∏ axis)^(1/n), then min(Λ, LAMBDA_CEILING). A single
|
| 43 |
+
zero axis vetoes the action (conjunctive veto / soundness A1). Λ is NEVER 1.0.
|
| 44 |
+
"""
|
| 45 |
+
vals = [max(0.0, min(1.0, float(v))) for v in axes.values()] or [0.0]
|
| 46 |
+
if any(v <= 0.0 for v in vals):
|
| 47 |
+
return 0.0
|
| 48 |
+
prod = 1.0
|
| 49 |
+
for v in vals:
|
| 50 |
+
prod *= v
|
| 51 |
+
lam = prod ** (1.0 / len(vals))
|
| 52 |
+
return round(min(lam, LAMBDA_CEILING), 6)
|
| 53 |
+
|
| 54 |
+
|
| 55 |
+
def quorum_status(present: list[str] | None = None, n: int = 4, f: int = 1) -> dict[str, Any]:
|
| 56 |
+
"""Khipu 3-of-4 (n≥3f+1) quorum arithmetic. Honest: live_polled False unless
|
| 57 |
+
a `present` witness set is supplied (matches hatun-mcp mesh_quorum_status)."""
|
| 58 |
+
need = 2 * f + 1 # 3-of-4 safe fragment
|
| 59 |
+
present = present or []
|
| 60 |
+
return {
|
| 61 |
+
"scheme": f"{need}-of-{n}", "n": n, "f": f, "need": need,
|
| 62 |
+
"present": present, "present_count": len(present),
|
| 63 |
+
"satisfied": len(present) >= need,
|
| 64 |
+
"live_polled": bool(present),
|
| 65 |
+
"note": "Khipu safe-fragment quorum (BFT safety = Conjecture 2, OPEN)" if not present
|
| 66 |
+
else "witness set supplied",
|
| 67 |
+
}
|
| 68 |
+
|
| 69 |
+
|
| 70 |
+
def _dsse_sign(payload: dict[str, Any]) -> dict[str, Any]:
|
| 71 |
+
"""Sign a receipt payload via the live szl_dsse module if importable; else an
|
| 72 |
+
honest UNSIGNED envelope (NEVER a fabricated signature)."""
|
| 73 |
+
try:
|
| 74 |
+
import szl_dsse # the live in-image DSSE/Cosign module
|
| 75 |
+
return szl_dsse.sign_payload(payload, "application/vnd.szl.khipu+json")
|
| 76 |
+
except Exception:
|
| 77 |
+
# honest fallback when szl_dsse is not importable in this context
|
| 78 |
+
body = json.dumps(payload, sort_keys=True, separators=(",", ":")).encode()
|
| 79 |
+
import base64
|
| 80 |
+
return {
|
| 81 |
+
"payloadType": "application/vnd.szl.khipu+json",
|
| 82 |
+
"payload": base64.b64encode(body).decode("ascii"),
|
| 83 |
+
"signatures": [],
|
| 84 |
+
"signed": False,
|
| 85 |
+
"honesty": ("UNSIGNED — szl_dsse unavailable in this runtime; "
|
| 86 |
+
"no signature fabricated."),
|
| 87 |
+
"_signed_at": _now(),
|
| 88 |
+
}
|
| 89 |
+
|
| 90 |
+
|
| 91 |
+
def receipt_for_write(*, connector_id: str, action: dict[str, Any],
|
| 92 |
+
lambda_value: float, cred_fingerprints: dict[str, str] | None = None,
|
| 93 |
+
quorum: dict[str, Any] | None = None,
|
| 94 |
+
result_summary: dict[str, Any] | None = None) -> dict[str, Any]:
|
| 95 |
+
"""Build + DSSE-sign a Khipu receipt for a connector write.
|
| 96 |
+
|
| 97 |
+
The receipt body carries the connector id, the action *shape* (method/object,
|
| 98 |
+
NOT secret values), the Λ score, quorum status, credential FINGERPRINT HASHES
|
| 99 |
+
(never the values), and a result summary. Returns {receipt_hash, dsse, body}.
|
| 100 |
+
"""
|
| 101 |
+
# scrub the action of anything secret-looking; keep only shape
|
| 102 |
+
safe_action = {k: v for k, v in (action or {}).items()
|
| 103 |
+
if k.lower() not in ("token", "secret", "password", "api_key", "key")}
|
| 104 |
+
body = {
|
| 105 |
+
"kind": "szl.connector.write",
|
| 106 |
+
"connector_id": connector_id,
|
| 107 |
+
"action": safe_action,
|
| 108 |
+
"lambda_value": lambda_value,
|
| 109 |
+
"lambda_note": "Λ never 1.0 (conformal anti-overconfidence floor 1/(n+1)); Λ = Conjecture 1",
|
| 110 |
+
"quorum": quorum or quorum_status(),
|
| 111 |
+
"credential_fingerprints": cred_fingerprints or {},
|
| 112 |
+
"result": result_summary or {},
|
| 113 |
+
"emitted_at": _now(),
|
| 114 |
+
"doctrine": "v11 — Λ-gate + DSSE/Khipu receipt on every write; no committed keys; trust never 100%",
|
| 115 |
+
}
|
| 116 |
+
receipt_hash = "sha256:" + hashlib.sha256(
|
| 117 |
+
json.dumps(body, sort_keys=True, separators=(",", ":")).encode()).hexdigest()
|
| 118 |
+
body["receipt_hash"] = receipt_hash
|
| 119 |
+
dsse = _dsse_sign(body)
|
| 120 |
+
return {"receipt_hash": receipt_hash, "dsse": dsse, "body": body}
|
| 121 |
+
|
| 122 |
+
|
| 123 |
+
def gate_write(*, connector_id: str, connected: bool, action: dict[str, Any],
|
| 124 |
+
cred_fingerprints: dict[str, str] | None = None,
|
| 125 |
+
quorum_present: list[str] | None = None,
|
| 126 |
+
extra_axes: dict[str, float] | None = None):
|
| 127 |
+
"""Run the full governed write gate. Returns (allowed: bool, lambda_value,
|
| 128 |
+
receipt_dict, quorum_dict, detail).
|
| 129 |
+
|
| 130 |
+
Λ axes (each ∈[0,1], conjunctive):
|
| 131 |
+
connected — connector is CONNECTED (refuse writes until creds activate it)
|
| 132 |
+
input_ok — action shape is well-formed (has a method/object)
|
| 133 |
+
no_secret_leak — no raw secret in the action body
|
| 134 |
+
quorum_ok — 2-person / 3-of-4 quorum satisfied (or honest pending)
|
| 135 |
+
"""
|
| 136 |
+
has_method = bool((action or {}).get("method") or (action or {}).get("object")
|
| 137 |
+
or (action or {}).get("doctype") or (action or {}).get("sobject"))
|
| 138 |
+
leak = any(k.lower() in ("token", "secret", "password") for k in (action or {}))
|
| 139 |
+
q = quorum_status(present=quorum_present)
|
| 140 |
+
axes = {
|
| 141 |
+
"connected": 1.0 if connected else 0.0,
|
| 142 |
+
"input_ok": 1.0 if has_method else 0.0,
|
| 143 |
+
"no_secret_leak": 0.0 if leak else 1.0,
|
| 144 |
+
"quorum_ok": 1.0 if q["satisfied"] else 0.5, # pending quorum lowers Λ, doesn't fabricate
|
| 145 |
+
}
|
| 146 |
+
if extra_axes:
|
| 147 |
+
axes.update(extra_axes)
|
| 148 |
+
lam = lambda_score(axes)
|
| 149 |
+
# write is allowed only when CONNECTED + well-formed + no leak.
|
| 150 |
+
allowed = connected and has_method and not leak
|
| 151 |
+
detail = ""
|
| 152 |
+
if not connected:
|
| 153 |
+
detail = "write refused — connector not CONNECTED (provide credentials to activate)"
|
| 154 |
+
elif not has_method:
|
| 155 |
+
detail = "write refused — action shape missing method/object"
|
| 156 |
+
elif leak:
|
| 157 |
+
detail = "write refused — raw secret detected in action body (doctrine: env/secret only)"
|
| 158 |
+
elif not q["satisfied"]:
|
| 159 |
+
detail = "write staged — 2-person / 3-of-4 Khipu quorum pending (state-changing gate)"
|
| 160 |
+
receipt = receipt_for_write(
|
| 161 |
+
connector_id=connector_id, action=action, lambda_value=lam,
|
| 162 |
+
cred_fingerprints=cred_fingerprints, quorum=q,
|
| 163 |
+
result_summary={"allowed": allowed, "detail": detail},
|
| 164 |
+
)
|
| 165 |
+
return allowed, lam, receipt, q, detail
|
| 166 |
+
|
| 167 |
+
|
| 168 |
+
__all__ = ["lambda_score", "quorum_status", "receipt_for_write", "gate_write",
|
| 169 |
+
"LAMBDA_CEILING"]
|
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# SPDX-License-Identifier: Apache-2.0
|
| 2 |
+
# © 2026 SZL Holdings. Identity/IdP connectors (Okta/Entra/Auth0 — credential-READY).
|
| 3 |
+
from . import identity_connectors # noqa: F401
|
|
@@ -0,0 +1,108 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# SPDX-License-Identifier: Apache-2.0
|
| 2 |
+
# © 2026 Lutar, Stephen P. — SZL Holdings · Doctrine v11 · Perplexity Computer Agent.
|
| 3 |
+
"""Identity / IdP connectors — Okta, Microsoft Entra ID (Graph), Auth0.
|
| 4 |
+
|
| 5 |
+
REAL clients against documented endpoints. NO creds → READY + exact secret name;
|
| 6 |
+
NEVER fabricates a record. Creds present → CONNECTED → live directory data.
|
| 7 |
+
|
| 8 |
+
API refs (publicly documented shapes):
|
| 9 |
+
Okta Users API https://developer.okta.com/docs/reference/api/users/
|
| 10 |
+
Microsoft Graph https://learn.microsoft.com/graph/api/user-list
|
| 11 |
+
Auth0 Mgmt API https://auth0.com/docs/api/management/v2
|
| 12 |
+
"""
|
| 13 |
+
from __future__ import annotations
|
| 14 |
+
|
| 15 |
+
import os
|
| 16 |
+
|
| 17 |
+
from ..ready import ReadyConnector
|
| 18 |
+
from ..registry import register
|
| 19 |
+
|
| 20 |
+
|
| 21 |
+
# ── Okta (api_key SSWS token OR oauth2; free developer org) ───────────────────
|
| 22 |
+
@register
|
| 23 |
+
class OktaConnector(ReadyConnector):
|
| 24 |
+
id = "okta"
|
| 25 |
+
label = "Okta (Universal Directory)"
|
| 26 |
+
category = "identity"
|
| 27 |
+
auth_kind = "api_key"
|
| 28 |
+
free_tier = True # free developer org
|
| 29 |
+
env_vars = ["SZL_OKTA_ORG_URL", "SZL_OKTA_API_TOKEN"]
|
| 30 |
+
_primary_secret = "SZL_OKTA_API_TOKEN"
|
| 31 |
+
provider_base = "{org_url}/api/v1"
|
| 32 |
+
docs_url = "https://developer.okta.com/docs/reference/api/users/"
|
| 33 |
+
schema_preview = ["id", "status", "profile.login", "profile.email"]
|
| 34 |
+
_read_path = "users?limit=10"
|
| 35 |
+
_record_path = ""
|
| 36 |
+
|
| 37 |
+
def _base_url(self):
|
| 38 |
+
return self.provider_base.replace("{org_url}",
|
| 39 |
+
os.environ.get("SZL_OKTA_ORG_URL", "").rstrip("/"))
|
| 40 |
+
|
| 41 |
+
def _auth_header(self):
|
| 42 |
+
tok = os.environ.get("SZL_OKTA_API_TOKEN")
|
| 43 |
+
return {"Authorization": f"SSWS {tok}"} if tok else {}
|
| 44 |
+
|
| 45 |
+
def _dig(self, raw):
|
| 46 |
+
rows = raw if isinstance(raw, list) else []
|
| 47 |
+
out = []
|
| 48 |
+
for r in rows:
|
| 49 |
+
if isinstance(r, dict):
|
| 50 |
+
p = r.get("profile", {}) or {}
|
| 51 |
+
out.append({"id": r.get("id"), "status": r.get("status"),
|
| 52 |
+
"login": p.get("login"), "email": p.get("email")})
|
| 53 |
+
return out
|
| 54 |
+
|
| 55 |
+
|
| 56 |
+
# ── Microsoft Entra ID (Graph; oauth2 client-credentials) ─────────────────────
|
| 57 |
+
@register
|
| 58 |
+
class EntraConnector(ReadyConnector):
|
| 59 |
+
id = "entra"
|
| 60 |
+
label = "Microsoft Entra ID (Graph)"
|
| 61 |
+
category = "identity"
|
| 62 |
+
auth_kind = "oauth2"
|
| 63 |
+
free_tier = True # free Azure AD tier / dev tenant
|
| 64 |
+
env_vars = ["SZL_ENTRA_ACCESS_TOKEN", "SZL_ENTRA_TENANT_ID",
|
| 65 |
+
"SZL_ENTRA_CLIENT_ID", "SZL_ENTRA_CLIENT_SECRET"]
|
| 66 |
+
_primary_secret = "SZL_ENTRA_ACCESS_TOKEN"
|
| 67 |
+
provider_base = "https://graph.microsoft.com/v1.0"
|
| 68 |
+
docs_url = "https://learn.microsoft.com/graph/api/user-list"
|
| 69 |
+
schema_preview = ["id", "displayName", "userPrincipalName", "mail"]
|
| 70 |
+
_read_path = "users?$top=10&$select=id,displayName,userPrincipalName,mail"
|
| 71 |
+
_record_path = "value"
|
| 72 |
+
|
| 73 |
+
def _auth_header(self):
|
| 74 |
+
tok = os.environ.get("SZL_ENTRA_ACCESS_TOKEN")
|
| 75 |
+
return {"Authorization": f"Bearer {tok}"} if tok else {}
|
| 76 |
+
|
| 77 |
+
|
| 78 |
+
# ── Auth0 (oauth2 mgmt token; free tier) ──────────────────────────────────────
|
| 79 |
+
@register
|
| 80 |
+
class Auth0Connector(ReadyConnector):
|
| 81 |
+
id = "auth0"
|
| 82 |
+
label = "Auth0 (Management API)"
|
| 83 |
+
category = "identity"
|
| 84 |
+
auth_kind = "oauth2"
|
| 85 |
+
free_tier = True
|
| 86 |
+
env_vars = ["SZL_AUTH0_DOMAIN", "SZL_AUTH0_MGMT_TOKEN"]
|
| 87 |
+
_primary_secret = "SZL_AUTH0_MGMT_TOKEN"
|
| 88 |
+
provider_base = "https://{domain}/api/v2"
|
| 89 |
+
docs_url = "https://auth0.com/docs/api/management/v2"
|
| 90 |
+
schema_preview = ["user_id", "name", "email", "last_login"]
|
| 91 |
+
_read_path = "users?per_page=10"
|
| 92 |
+
_record_path = ""
|
| 93 |
+
|
| 94 |
+
def _base_url(self):
|
| 95 |
+
return self.provider_base.replace("{domain}",
|
| 96 |
+
os.environ.get("SZL_AUTH0_DOMAIN", "").replace("https://", "").rstrip("/"))
|
| 97 |
+
|
| 98 |
+
def _auth_header(self):
|
| 99 |
+
tok = os.environ.get("SZL_AUTH0_MGMT_TOKEN")
|
| 100 |
+
return {"Authorization": f"Bearer {tok}"} if tok else {}
|
| 101 |
+
|
| 102 |
+
def _dig(self, raw):
|
| 103 |
+
rows = raw if isinstance(raw, list) else (raw.get("users", []) if isinstance(raw, dict) else [])
|
| 104 |
+
return [{k: r.get(k) for k in self.schema_preview if k in r}
|
| 105 |
+
for r in rows if isinstance(r, dict)]
|
| 106 |
+
|
| 107 |
+
|
| 108 |
+
__all__ = ["OktaConnector", "EntraConnector", "Auth0Connector"]
|
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# SPDX-License-Identifier: Apache-2.0
|
| 2 |
+
# © 2026 SZL Holdings. ITSM/ticketing connectors (Jira/ServiceNow/Zendesk/Linear).
|
| 3 |
+
from . import itsm_connectors # noqa: F401
|
|
@@ -0,0 +1,163 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# SPDX-License-Identifier: Apache-2.0
|
| 2 |
+
# © 2026 Lutar, Stephen P. — SZL Holdings · Doctrine v11 · Perplexity Computer Agent.
|
| 3 |
+
"""ITSM / ticketing connectors — Jira, ServiceNow, Zendesk, Linear.
|
| 4 |
+
|
| 5 |
+
REAL clients against documented endpoints. NO creds → READY + exact secret name.
|
| 6 |
+
Jira is WRITABLE (create issue) — every write is Λ-gated + DSSE-receipted.
|
| 7 |
+
|
| 8 |
+
API refs (publicly documented shapes):
|
| 9 |
+
Jira Cloud REST v3 https://developer.atlassian.com/cloud/jira/platform/rest/v3/
|
| 10 |
+
ServiceNow Table https://developer.servicenow.com/dev.do#!/reference/api/
|
| 11 |
+
Zendesk Tickets https://developer.zendesk.com/api-reference/ticketing/tickets/tickets/
|
| 12 |
+
Linear GraphQL https://developers.linear.app/docs/graphql/working-with-the-graphql-api
|
| 13 |
+
"""
|
| 14 |
+
from __future__ import annotations
|
| 15 |
+
|
| 16 |
+
import base64
|
| 17 |
+
import os
|
| 18 |
+
|
| 19 |
+
from ..base import State, Records, http_json
|
| 20 |
+
from ..ready import ReadyConnector, WritableReadyConnector
|
| 21 |
+
from ..registry import register
|
| 22 |
+
|
| 23 |
+
|
| 24 |
+
# ── Jira Cloud (api_key basic email:token; free tier) — WRITABLE ──────────────
|
| 25 |
+
@register
|
| 26 |
+
class JiraConnector(WritableReadyConnector):
|
| 27 |
+
id = "jira"
|
| 28 |
+
label = "Atlassian Jira (Cloud)"
|
| 29 |
+
category = "itsm"
|
| 30 |
+
auth_kind = "api_key"
|
| 31 |
+
free_tier = True
|
| 32 |
+
env_vars = ["SZL_JIRA_SITE_URL", "SZL_JIRA_EMAIL", "SZL_JIRA_API_TOKEN"]
|
| 33 |
+
_primary_secret = "SZL_JIRA_API_TOKEN"
|
| 34 |
+
provider_base = "{site_url}/rest/api/3"
|
| 35 |
+
docs_url = "https://developer.atlassian.com/cloud/jira/platform/rest/v3/"
|
| 36 |
+
schema_preview = ["key", "fields.summary", "fields.status", "fields.assignee"]
|
| 37 |
+
_read_path = "search?maxResults=10&fields=summary,status,assignee"
|
| 38 |
+
_record_path = "issues"
|
| 39 |
+
_write_path = "issue"
|
| 40 |
+
|
| 41 |
+
def _base_url(self):
|
| 42 |
+
return self.provider_base.replace("{site_url}",
|
| 43 |
+
os.environ.get("SZL_JIRA_SITE_URL", "").rstrip("/"))
|
| 44 |
+
|
| 45 |
+
def _auth_header(self):
|
| 46 |
+
email = os.environ.get("SZL_JIRA_EMAIL", "")
|
| 47 |
+
tok = os.environ.get("SZL_JIRA_API_TOKEN", "")
|
| 48 |
+
if not tok:
|
| 49 |
+
return {}
|
| 50 |
+
b = base64.b64encode(f"{email}:{tok}".encode()).decode()
|
| 51 |
+
return {"Authorization": f"Basic {b}"}
|
| 52 |
+
|
| 53 |
+
def _dig(self, raw):
|
| 54 |
+
rows = (raw or {}).get("issues", []) if isinstance(raw, dict) else []
|
| 55 |
+
out = []
|
| 56 |
+
for r in rows:
|
| 57 |
+
f = r.get("fields", {}) if isinstance(r, dict) else {}
|
| 58 |
+
out.append({"key": r.get("key"), "summary": f.get("summary"),
|
| 59 |
+
"status": (f.get("status") or {}).get("name"),
|
| 60 |
+
"assignee": (f.get("assignee") or {}).get("displayName")})
|
| 61 |
+
return out
|
| 62 |
+
|
| 63 |
+
|
| 64 |
+
# ── ServiceNow (Table API; basic auth; no free tier — PDI dev instance) ───────
|
| 65 |
+
@register
|
| 66 |
+
class ServiceNowConnector(ReadyConnector):
|
| 67 |
+
id = "servicenow"
|
| 68 |
+
label = "ServiceNow (Table API)"
|
| 69 |
+
category = "itsm"
|
| 70 |
+
auth_kind = "basic"
|
| 71 |
+
free_tier = True # free Personal Developer Instance
|
| 72 |
+
env_vars = ["SZL_SERVICENOW_INSTANCE", "SZL_SERVICENOW_USER", "SZL_SERVICENOW_PASSWORD"]
|
| 73 |
+
_primary_secret = "SZL_SERVICENOW_PASSWORD"
|
| 74 |
+
provider_base = "https://{instance}.service-now.com/api/now"
|
| 75 |
+
docs_url = "https://developer.servicenow.com/dev.do#!/reference/api/"
|
| 76 |
+
schema_preview = ["number", "short_description", "state", "priority"]
|
| 77 |
+
_read_path = "table/incident?sysparm_limit=10&sysparm_fields=number,short_description,state,priority"
|
| 78 |
+
_record_path = "result"
|
| 79 |
+
|
| 80 |
+
def _base_url(self):
|
| 81 |
+
return self.provider_base.replace("{instance}",
|
| 82 |
+
os.environ.get("SZL_SERVICENOW_INSTANCE", ""))
|
| 83 |
+
|
| 84 |
+
def _auth_header(self):
|
| 85 |
+
u = os.environ.get("SZL_SERVICENOW_USER", "")
|
| 86 |
+
p = os.environ.get("SZL_SERVICENOW_PASSWORD", "")
|
| 87 |
+
if not p:
|
| 88 |
+
return {}
|
| 89 |
+
b = base64.b64encode(f"{u}:{p}".encode()).decode()
|
| 90 |
+
return {"Authorization": f"Basic {b}"}
|
| 91 |
+
|
| 92 |
+
|
| 93 |
+
# ── Zendesk (api_key email/token basic; free trial) ───────────────────────────
|
| 94 |
+
@register
|
| 95 |
+
class ZendeskConnector(ReadyConnector):
|
| 96 |
+
id = "zendesk"
|
| 97 |
+
label = "Zendesk Support"
|
| 98 |
+
category = "itsm"
|
| 99 |
+
auth_kind = "api_key"
|
| 100 |
+
free_tier = False
|
| 101 |
+
env_vars = ["SZL_ZENDESK_SUBDOMAIN", "SZL_ZENDESK_EMAIL", "SZL_ZENDESK_API_TOKEN"]
|
| 102 |
+
_primary_secret = "SZL_ZENDESK_API_TOKEN"
|
| 103 |
+
provider_base = "https://{subdomain}.zendesk.com/api/v2"
|
| 104 |
+
docs_url = "https://developer.zendesk.com/api-reference/ticketing/tickets/tickets/"
|
| 105 |
+
schema_preview = ["id", "subject", "status", "priority"]
|
| 106 |
+
_read_path = "tickets.json?per_page=10"
|
| 107 |
+
_record_path = "tickets"
|
| 108 |
+
|
| 109 |
+
def _base_url(self):
|
| 110 |
+
return self.provider_base.replace("{subdomain}",
|
| 111 |
+
os.environ.get("SZL_ZENDESK_SUBDOMAIN", ""))
|
| 112 |
+
|
| 113 |
+
def _auth_header(self):
|
| 114 |
+
email = os.environ.get("SZL_ZENDESK_EMAIL", "")
|
| 115 |
+
tok = os.environ.get("SZL_ZENDESK_API_TOKEN", "")
|
| 116 |
+
if not tok:
|
| 117 |
+
return {}
|
| 118 |
+
b = base64.b64encode(f"{email}/token:{tok}".encode()).decode()
|
| 119 |
+
return {"Authorization": f"Basic {b}"}
|
| 120 |
+
|
| 121 |
+
|
| 122 |
+
# ── Linear (api_key personal token; GraphQL; free tier) ───────────────────────
|
| 123 |
+
@register
|
| 124 |
+
class LinearConnector(ReadyConnector):
|
| 125 |
+
id = "linear"
|
| 126 |
+
label = "Linear"
|
| 127 |
+
category = "itsm"
|
| 128 |
+
auth_kind = "api_key"
|
| 129 |
+
free_tier = True
|
| 130 |
+
env_vars = ["SZL_LINEAR_API_KEY"]
|
| 131 |
+
_primary_secret = "SZL_LINEAR_API_KEY"
|
| 132 |
+
provider_base = "https://api.linear.app/graphql"
|
| 133 |
+
docs_url = "https://developers.linear.app/docs/graphql/working-with-the-graphql-api"
|
| 134 |
+
schema_preview = ["id", "identifier", "title", "state"]
|
| 135 |
+
|
| 136 |
+
def _auth_header(self):
|
| 137 |
+
tok = os.environ.get("SZL_LINEAR_API_KEY")
|
| 138 |
+
return {"Authorization": tok} if tok else {}
|
| 139 |
+
|
| 140 |
+
def read(self, query=None):
|
| 141 |
+
if self._primary_missing():
|
| 142 |
+
return self._ready_records(
|
| 143 |
+
"provide credentials to activate — set SZL_LINEAR_API_KEY. "
|
| 144 |
+
"POSTs GraphQL { issues } to api.linear.app/graphql.")
|
| 145 |
+
import json as _json
|
| 146 |
+
gql = {"query": "{ issues(first: 10) { nodes { id identifier title state { name } } } }"}
|
| 147 |
+
st, raw = http_json(self.provider_base, method="POST",
|
| 148 |
+
headers={"Content-Type": "application/json", **self._auth_header()},
|
| 149 |
+
data=_json.dumps(gql).encode())
|
| 150 |
+
if st == 200 and isinstance(raw, dict):
|
| 151 |
+
nodes = (((raw.get("data") or {}).get("issues") or {}).get("nodes")) or []
|
| 152 |
+
proj = [{"id": n.get("id"), "identifier": n.get("identifier"),
|
| 153 |
+
"title": n.get("title"), "state": (n.get("state") or {}).get("name")}
|
| 154 |
+
for n in nodes]
|
| 155 |
+
return Records(connector_id=self.id, category=self.category, state=State.CONNECTED,
|
| 156 |
+
records=proj, source="Linear GraphQL issues", live=True,
|
| 157 |
+
note=f"live · {len(nodes)} issues", schema_preview=self.schema_preview)
|
| 158 |
+
return Records(connector_id=self.id, category=self.category, state=State.ERROR,
|
| 159 |
+
records=[], source=self.provider_base, live=False,
|
| 160 |
+
note=f"credentials present but Linear HTTP {st}", schema_preview=self.schema_preview)
|
| 161 |
+
|
| 162 |
+
|
| 163 |
+
__all__ = ["JiraConnector", "ServiceNowConnector", "ZendeskConnector", "LinearConnector"]
|
|
@@ -0,0 +1,240 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# SPDX-License-Identifier: Apache-2.0
|
| 2 |
+
# © 2026 Lutar, Stephen P. — SZL Holdings · ORCID 0009-0001-0110-4173 · Doctrine v11
|
| 3 |
+
# Authored by SZL Enterprise-Integration Team. Co-Authored-By: Perplexity Computer Agent.
|
| 4 |
+
"""szl_connectors.oauth — the OAuth2 authorization-code + PKCE flow engine (P2).
|
| 5 |
+
|
| 6 |
+
Designed NOW; activates the instant a customer provides an OAuth app
|
| 7 |
+
registration (CLIENT_ID/CLIENT_SECRET). The redirect URI is a stable per-app
|
| 8 |
+
path: https://<space>/api/{app}/v1/connectors/{id}/oauth/callback
|
| 9 |
+
|
| 10 |
+
Flow (per ENTERPRISE_INTEGRATION_SPEC §1.3):
|
| 11 |
+
1. /oauth/start → 302 to provider authorize URL with client_id, redirect_uri,
|
| 12 |
+
scope, state (= signed nonce), code_challenge (PKCE S256).
|
| 13 |
+
2. provider → user consents → redirects to /oauth/callback?code=&state=
|
| 14 |
+
3. /oauth/callback → verify signed state nonce → POST code to provider /token
|
| 15 |
+
(client_id + client_secret + code_verifier) → access_token
|
| 16 |
+
+ refresh_token.
|
| 17 |
+
4. Persist ONLY to the secret store (env injector / Space secret) — NEVER to a
|
| 18 |
+
file or commit. Emit a DSSE "credential-bound" receipt carrying ONLY the
|
| 19 |
+
connector id, granted scope, and a credential FINGERPRINT HASH (never the
|
| 20 |
+
secret value).
|
| 21 |
+
5. health() now sees the refresh token → exchanges for access token → CONNECTED.
|
| 22 |
+
|
| 23 |
+
DOCTRINE: no committed keys; the secret value never leaves the secret store and
|
| 24 |
+
never enters a receipt body. Until CLIENT_ID/SECRET are provided, the connector
|
| 25 |
+
stays READY and the UI shows "Connect <Provider>" which initiates this flow.
|
| 26 |
+
"""
|
| 27 |
+
from __future__ import annotations
|
| 28 |
+
|
| 29 |
+
import base64
|
| 30 |
+
import hashlib
|
| 31 |
+
import hmac
|
| 32 |
+
import json
|
| 33 |
+
import os
|
| 34 |
+
import time
|
| 35 |
+
import urllib.parse as _up
|
| 36 |
+
from datetime import datetime, timezone
|
| 37 |
+
from typing import Any
|
| 38 |
+
|
| 39 |
+
# Per-provider OAuth2 endpoints (publicly documented authorize/token URLs).
|
| 40 |
+
# {sub} placeholders are filled from the connector's env (instance/tenant/domain).
|
| 41 |
+
PROVIDER_OAUTH: dict[str, dict[str, str]] = {
|
| 42 |
+
"salesforce": {
|
| 43 |
+
"authorize": "https://login.salesforce.com/services/oauth2/authorize",
|
| 44 |
+
"token": "https://login.salesforce.com/services/oauth2/token",
|
| 45 |
+
"scope": "api refresh_token",
|
| 46 |
+
},
|
| 47 |
+
"hubspot": {
|
| 48 |
+
"authorize": "https://app.hubspot.com/oauth/authorize",
|
| 49 |
+
"token": "https://api.hubapi.com/oauth/v1/token",
|
| 50 |
+
"scope": "crm.objects.contacts.read crm.objects.companies.read crm.objects.deals.read",
|
| 51 |
+
},
|
| 52 |
+
"zoho_crm": {
|
| 53 |
+
"authorize": "https://accounts.zoho.com/oauth/v2/auth",
|
| 54 |
+
"token": "https://accounts.zoho.com/oauth/v2/token",
|
| 55 |
+
"scope": "ZohoCRM.modules.ALL ZohoCRM.org.READ",
|
| 56 |
+
},
|
| 57 |
+
"slack": {
|
| 58 |
+
"authorize": "https://slack.com/oauth/v2/authorize",
|
| 59 |
+
"token": "https://slack.com/api/oauth.v2.access",
|
| 60 |
+
"scope": "channels:read chat:write users:read",
|
| 61 |
+
},
|
| 62 |
+
"okta": {
|
| 63 |
+
"authorize": "https://{org}.okta.com/oauth2/v1/authorize",
|
| 64 |
+
"token": "https://{org}.okta.com/oauth2/v1/token",
|
| 65 |
+
"scope": "okta.users.read okta.groups.read",
|
| 66 |
+
},
|
| 67 |
+
"entra": {
|
| 68 |
+
"authorize": "https://login.microsoftonline.com/{tenant}/oauth2/v2.0/authorize",
|
| 69 |
+
"token": "https://login.microsoftonline.com/{tenant}/oauth2/v2.0/token",
|
| 70 |
+
"scope": "https://graph.microsoft.com/.default offline_access",
|
| 71 |
+
},
|
| 72 |
+
"auth0": {
|
| 73 |
+
"authorize": "https://{tenant}.auth0.com/authorize",
|
| 74 |
+
"token": "https://{tenant}.auth0.com/oauth/token",
|
| 75 |
+
"scope": "read:users read:logs",
|
| 76 |
+
},
|
| 77 |
+
"dynamics_crm": {
|
| 78 |
+
"authorize": "https://login.microsoftonline.com/{tenant}/oauth2/v2.0/authorize",
|
| 79 |
+
"token": "https://login.microsoftonline.com/{tenant}/oauth2/v2.0/token",
|
| 80 |
+
"scope": "https://{org}.api.crm.dynamics.com/.default offline_access",
|
| 81 |
+
},
|
| 82 |
+
"netsuite": {
|
| 83 |
+
"authorize": "https://{account}.app.netsuite.com/app/login/oauth2/authorize.nl",
|
| 84 |
+
"token": "https://{account}.suitetalk.api.netsuite.com/services/rest/auth/oauth2/v1/token",
|
| 85 |
+
"scope": "rest_webservices",
|
| 86 |
+
},
|
| 87 |
+
"servicenow": {
|
| 88 |
+
"authorize": "https://{instance}.service-now.com/oauth_auth.do",
|
| 89 |
+
"token": "https://{instance}.service-now.com/oauth_token.do",
|
| 90 |
+
"scope": "useraccount",
|
| 91 |
+
},
|
| 92 |
+
}
|
| 93 |
+
|
| 94 |
+
|
| 95 |
+
def _state_secret() -> bytes:
|
| 96 |
+
# signed-state nonce key: reuse the cosign-adjacent secret if present, else a
|
| 97 |
+
# per-process ephemeral key (state still verifiable within the process).
|
| 98 |
+
s = os.environ.get("SZL_OAUTH_STATE_SECRET") or os.environ.get("SZL_COSIGN_PRIVATE_PEM") or "szl-oauth-ephemeral"
|
| 99 |
+
return hashlib.sha256(s.encode()).digest()
|
| 100 |
+
|
| 101 |
+
|
| 102 |
+
def _b64u(b: bytes) -> str:
|
| 103 |
+
return base64.urlsafe_b64encode(b).decode().rstrip("=")
|
| 104 |
+
|
| 105 |
+
|
| 106 |
+
def make_pkce() -> tuple[str, str]:
|
| 107 |
+
"""Return (code_verifier, code_challenge[S256])."""
|
| 108 |
+
verifier = _b64u(os.urandom(48))
|
| 109 |
+
challenge = _b64u(hashlib.sha256(verifier.encode()).digest())
|
| 110 |
+
return verifier, challenge
|
| 111 |
+
|
| 112 |
+
|
| 113 |
+
def sign_state(connector_id: str, nonce: str, ts: int | None = None) -> str:
|
| 114 |
+
ts = ts or int(time.time())
|
| 115 |
+
msg = f"{connector_id}.{nonce}.{ts}"
|
| 116 |
+
sig = hmac.new(_state_secret(), msg.encode(), hashlib.sha256).hexdigest()[:24]
|
| 117 |
+
return _b64u(f"{msg}.{sig}".encode())
|
| 118 |
+
|
| 119 |
+
|
| 120 |
+
def verify_state(state: str, connector_id: str, max_age: int = 600) -> tuple[bool, str]:
|
| 121 |
+
try:
|
| 122 |
+
decoded = base64.urlsafe_b64decode(state + "=" * (-len(state) % 4)).decode()
|
| 123 |
+
cid, nonce, ts, sig = decoded.rsplit(".", 3)
|
| 124 |
+
if cid != connector_id:
|
| 125 |
+
return False, "state connector_id mismatch"
|
| 126 |
+
msg = f"{cid}.{nonce}.{ts}"
|
| 127 |
+
expect = hmac.new(_state_secret(), msg.encode(), hashlib.sha256).hexdigest()[:24]
|
| 128 |
+
if not hmac.compare_digest(expect, sig):
|
| 129 |
+
return False, "state signature mismatch"
|
| 130 |
+
if int(time.time()) - int(ts) > max_age:
|
| 131 |
+
return False, "state expired"
|
| 132 |
+
return True, "ok"
|
| 133 |
+
except Exception as e:
|
| 134 |
+
return False, f"malformed state: {type(e).__name__}"
|
| 135 |
+
|
| 136 |
+
|
| 137 |
+
# in-process PKCE verifier store (keyed by signed-state nonce). NOT a secret store;
|
| 138 |
+
# holds only ephemeral PKCE verifiers, cleared after callback.
|
| 139 |
+
_PKCE_STORE: dict[str, str] = {}
|
| 140 |
+
|
| 141 |
+
|
| 142 |
+
def build_authorize_url(connector_id: str, *, redirect_uri: str,
|
| 143 |
+
subs: dict[str, str] | None = None,
|
| 144 |
+
client_id: str | None = None) -> dict[str, Any]:
|
| 145 |
+
"""Build the provider authorize redirect (PKCE S256). Returns dict with `url`
|
| 146 |
+
or an honest error when the connector has no OAuth config / no client_id."""
|
| 147 |
+
cfg = PROVIDER_OAUTH.get(connector_id)
|
| 148 |
+
if not cfg:
|
| 149 |
+
return {"ok": False, "error": f"no OAuth2 config for '{connector_id}'"}
|
| 150 |
+
authorize = cfg["authorize"]
|
| 151 |
+
for k, v in (subs or {}).items():
|
| 152 |
+
authorize = authorize.replace("{" + k + "}", v)
|
| 153 |
+
if "{" in authorize:
|
| 154 |
+
return {"ok": False, "error": "OAuth authorize URL needs instance/tenant substitution",
|
| 155 |
+
"needs": authorize}
|
| 156 |
+
if not client_id:
|
| 157 |
+
return {"ok": False, "state": "READY",
|
| 158 |
+
"error": "no client_id — provide the OAuth app registration to connect",
|
| 159 |
+
"secret_hint": f"SZL_{connector_id.upper()}_CLIENT_ID"}
|
| 160 |
+
nonce = _b64u(os.urandom(12))
|
| 161 |
+
state = sign_state(connector_id, nonce)
|
| 162 |
+
verifier, challenge = make_pkce()
|
| 163 |
+
_PKCE_STORE[state] = verifier
|
| 164 |
+
params = {
|
| 165 |
+
"response_type": "code",
|
| 166 |
+
"client_id": client_id,
|
| 167 |
+
"redirect_uri": redirect_uri,
|
| 168 |
+
"scope": cfg.get("scope", ""),
|
| 169 |
+
"state": state,
|
| 170 |
+
"code_challenge": challenge,
|
| 171 |
+
"code_challenge_method": "S256",
|
| 172 |
+
}
|
| 173 |
+
return {"ok": True, "url": authorize + "?" + _up.urlencode(params),
|
| 174 |
+
"state": state, "pkce": True}
|
| 175 |
+
|
| 176 |
+
|
| 177 |
+
def exchange_code(connector_id: str, *, code: str, state: str, redirect_uri: str,
|
| 178 |
+
subs: dict[str, str] | None = None,
|
| 179 |
+
client_id: str | None = None, client_secret: str | None = None) -> dict[str, Any]:
|
| 180 |
+
"""Exchange the auth code for tokens, then emit a credential-bound DSSE receipt
|
| 181 |
+
(NO secret in the receipt — only a fingerprint hash). Persisting the refresh
|
| 182 |
+
token to the secret store is an out-of-band operation (Space secret); this
|
| 183 |
+
function NEVER writes a secret to a file or commit."""
|
| 184 |
+
ok, why = verify_state(state, connector_id)
|
| 185 |
+
if not ok:
|
| 186 |
+
return {"ok": False, "error": f"state verification failed: {why}"}
|
| 187 |
+
cfg = PROVIDER_OAUTH.get(connector_id)
|
| 188 |
+
if not cfg:
|
| 189 |
+
return {"ok": False, "error": f"no OAuth2 config for '{connector_id}'"}
|
| 190 |
+
if not (client_id and client_secret):
|
| 191 |
+
return {"ok": False, "error": "client_id/client_secret not provided (secret store)"}
|
| 192 |
+
verifier = _PKCE_STORE.pop(state, None)
|
| 193 |
+
token_url = cfg["token"]
|
| 194 |
+
for k, v in (subs or {}).items():
|
| 195 |
+
token_url = token_url.replace("{" + k + "}", v)
|
| 196 |
+
from .base import http_json, cred_fingerprint
|
| 197 |
+
payload = {
|
| 198 |
+
"grant_type": "authorization_code", "code": code,
|
| 199 |
+
"client_id": client_id, "client_secret": client_secret,
|
| 200 |
+
"redirect_uri": redirect_uri,
|
| 201 |
+
}
|
| 202 |
+
if verifier:
|
| 203 |
+
payload["code_verifier"] = verifier
|
| 204 |
+
status, body = http_json(token_url, method="POST",
|
| 205 |
+
headers={"Content-Type": "application/x-www-form-urlencoded"},
|
| 206 |
+
data=_up.urlencode(payload).encode())
|
| 207 |
+
if status != 200 or not isinstance(body, dict):
|
| 208 |
+
return {"ok": False, "error": f"token exchange HTTP {status}",
|
| 209 |
+
"provider_detail": str(body)[:200]}
|
| 210 |
+
refresh = body.get("refresh_token", "")
|
| 211 |
+
access = body.get("access_token", "")
|
| 212 |
+
# credential-bound DSSE receipt — fingerprint ONLY, never the token value.
|
| 213 |
+
from .governance import receipt_for_write
|
| 214 |
+
rcpt = receipt_for_write(
|
| 215 |
+
connector_id=connector_id,
|
| 216 |
+
action={"method": "oauth.credential_bound", "object": "refresh_token",
|
| 217 |
+
"scope": cfg.get("scope", "")},
|
| 218 |
+
lambda_value=0.9,
|
| 219 |
+
cred_fingerprints={
|
| 220 |
+
"refresh_token": cred_fingerprint(refresh) if refresh else "absent",
|
| 221 |
+
"access_token": cred_fingerprint(access) if access else "absent",
|
| 222 |
+
},
|
| 223 |
+
result_summary={"granted_scope": cfg.get("scope", ""),
|
| 224 |
+
"note": "secret persisted to Space secret store only; never committed"},
|
| 225 |
+
)
|
| 226 |
+
return {
|
| 227 |
+
"ok": True,
|
| 228 |
+
"connector_id": connector_id,
|
| 229 |
+
"credential_bound": True,
|
| 230 |
+
"store_secret_as": f"SZL_{connector_id.upper()}_REFRESH_TOKEN",
|
| 231 |
+
"note": ("Persist the refresh_token into the Space secret store as the named "
|
| 232 |
+
"secret. It is NEVER written to a file or commit. The Space restarts "
|
| 233 |
+
"(or hot-reloads env) → health() flips the connector to CONNECTED."),
|
| 234 |
+
"receipt_hash": rcpt["receipt_hash"],
|
| 235 |
+
"dsse": rcpt["dsse"],
|
| 236 |
+
}
|
| 237 |
+
|
| 238 |
+
|
| 239 |
+
__all__ = ["PROVIDER_OAUTH", "make_pkce", "sign_state", "verify_state",
|
| 240 |
+
"build_authorize_url", "exchange_code"]
|
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# SPDX-License-Identifier: Apache-2.0
|
| 2 |
+
# © 2026 SZL Holdings. Observability connectors (Datadog/Splunk/Grafana — credential-READY).
|
| 3 |
+
from . import observability_connectors # noqa: F401
|
|
@@ -0,0 +1,129 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# SPDX-License-Identifier: Apache-2.0
|
| 2 |
+
# © 2026 Lutar, Stephen P. — SZL Holdings · Doctrine v11 · Perplexity Computer Agent.
|
| 3 |
+
"""Observability connectors — Datadog, Splunk, Grafana.
|
| 4 |
+
|
| 5 |
+
REAL clients against documented endpoints. NO creds → READY + exact secret name;
|
| 6 |
+
NEVER fabricates a record. Reads are monitor/alert/dashboard listings.
|
| 7 |
+
|
| 8 |
+
API refs (publicly documented shapes):
|
| 9 |
+
Datadog API v1 https://docs.datadoghq.com/api/latest/monitors/
|
| 10 |
+
Splunk REST https://docs.splunk.com/Documentation/Splunk/latest/RESTREF/RESTsearch
|
| 11 |
+
Grafana HTTP API https://grafana.com/docs/grafana/latest/developers/http_api/
|
| 12 |
+
"""
|
| 13 |
+
from __future__ import annotations
|
| 14 |
+
|
| 15 |
+
import base64
|
| 16 |
+
import os
|
| 17 |
+
|
| 18 |
+
from ..base import State, Records, http_json
|
| 19 |
+
from ..ready import ReadyConnector
|
| 20 |
+
from ..registry import register
|
| 21 |
+
|
| 22 |
+
|
| 23 |
+
# ── Datadog (api_key + app key headers; free tier) ────────────────────────────
|
| 24 |
+
@register
|
| 25 |
+
class DatadogConnector(ReadyConnector):
|
| 26 |
+
id = "datadog"
|
| 27 |
+
label = "Datadog"
|
| 28 |
+
category = "observability"
|
| 29 |
+
auth_kind = "api_key"
|
| 30 |
+
free_tier = True # free monitoring tier
|
| 31 |
+
env_vars = ["SZL_DATADOG_API_KEY", "SZL_DATADOG_APP_KEY", "SZL_DATADOG_SITE"]
|
| 32 |
+
_primary_secret = "SZL_DATADOG_API_KEY"
|
| 33 |
+
provider_base = "https://api.{site}/api/v1"
|
| 34 |
+
docs_url = "https://docs.datadoghq.com/api/latest/monitors/"
|
| 35 |
+
schema_preview = ["id", "name", "type", "overall_state"]
|
| 36 |
+
|
| 37 |
+
def _base_url(self):
|
| 38 |
+
site = os.environ.get("SZL_DATADOG_SITE", "datadoghq.com")
|
| 39 |
+
return self.provider_base.replace("{site}", site)
|
| 40 |
+
|
| 41 |
+
def _auth_header(self):
|
| 42 |
+
api = os.environ.get("SZL_DATADOG_API_KEY", "")
|
| 43 |
+
app = os.environ.get("SZL_DATADOG_APP_KEY", "")
|
| 44 |
+
if not api:
|
| 45 |
+
return {}
|
| 46 |
+
return {"DD-API-KEY": api, "DD-APPLICATION-KEY": app}
|
| 47 |
+
|
| 48 |
+
def read(self, query=None):
|
| 49 |
+
if self._primary_missing():
|
| 50 |
+
return self._ready_records(
|
| 51 |
+
"provide credentials to activate — set SZL_DATADOG_API_KEY, "
|
| 52 |
+
"SZL_DATADOG_APP_KEY (+ optional SZL_DATADOG_SITE). Lists monitors.")
|
| 53 |
+
url = self._base_url() + "/monitor?page_size=10"
|
| 54 |
+
st, raw = http_json(url, headers={"Accept": "application/json", **self._auth_header()})
|
| 55 |
+
if st == 200 and isinstance(raw, list):
|
| 56 |
+
out = [{k: r.get(k) for k in self.schema_preview if k in r} for r in raw[:10]]
|
| 57 |
+
return Records(connector_id=self.id, category=self.category, state=State.CONNECTED,
|
| 58 |
+
records=out, source="Datadog monitors", live=True,
|
| 59 |
+
note=f"live · {len(raw)} monitors", schema_preview=self.schema_preview)
|
| 60 |
+
return Records(connector_id=self.id, category=self.category, state=State.ERROR,
|
| 61 |
+
records=[], source=self.provider_base, live=False,
|
| 62 |
+
note=f"credentials present but Datadog HTTP {st}", schema_preview=self.schema_preview)
|
| 63 |
+
|
| 64 |
+
|
| 65 |
+
# ── Splunk (REST; basic/token auth; free trial) ───────────────────────────────
|
| 66 |
+
@register
|
| 67 |
+
class SplunkConnector(ReadyConnector):
|
| 68 |
+
id = "splunk"
|
| 69 |
+
label = "Splunk"
|
| 70 |
+
category = "observability"
|
| 71 |
+
auth_kind = "api_key" # bearer token or basic
|
| 72 |
+
free_tier = False
|
| 73 |
+
env_vars = ["SZL_SPLUNK_HOST", "SZL_SPLUNK_TOKEN"]
|
| 74 |
+
_primary_secret = "SZL_SPLUNK_TOKEN"
|
| 75 |
+
provider_base = "{host}/services"
|
| 76 |
+
docs_url = "https://docs.splunk.com/Documentation/Splunk/latest/RESTREF/RESTsearch"
|
| 77 |
+
schema_preview = ["title", "disabled", "search"]
|
| 78 |
+
_read_path = "saved/searches?output_mode=json&count=10"
|
| 79 |
+
_record_path = "entry"
|
| 80 |
+
|
| 81 |
+
def _base_url(self):
|
| 82 |
+
return self.provider_base.replace("{host}",
|
| 83 |
+
os.environ.get("SZL_SPLUNK_HOST", "").rstrip("/"))
|
| 84 |
+
|
| 85 |
+
def _auth_header(self):
|
| 86 |
+
tok = os.environ.get("SZL_SPLUNK_TOKEN")
|
| 87 |
+
return {"Authorization": f"Bearer {tok}"} if tok else {}
|
| 88 |
+
|
| 89 |
+
def _dig(self, raw):
|
| 90 |
+
rows = (raw or {}).get("entry", []) if isinstance(raw, dict) else []
|
| 91 |
+
out = []
|
| 92 |
+
for r in rows:
|
| 93 |
+
c = r.get("content", {}) if isinstance(r, dict) else {}
|
| 94 |
+
out.append({"title": r.get("name"), "disabled": c.get("disabled"),
|
| 95 |
+
"search": c.get("search")})
|
| 96 |
+
return out
|
| 97 |
+
|
| 98 |
+
|
| 99 |
+
# ── Grafana (api_key service-account token; free OSS/Cloud) ───────────────────
|
| 100 |
+
@register
|
| 101 |
+
class GrafanaConnector(ReadyConnector):
|
| 102 |
+
id = "grafana"
|
| 103 |
+
label = "Grafana"
|
| 104 |
+
category = "observability"
|
| 105 |
+
auth_kind = "api_key"
|
| 106 |
+
free_tier = True # OSS + free cloud tier
|
| 107 |
+
env_vars = ["SZL_GRAFANA_URL", "SZL_GRAFANA_TOKEN"]
|
| 108 |
+
_primary_secret = "SZL_GRAFANA_TOKEN"
|
| 109 |
+
provider_base = "{url}/api"
|
| 110 |
+
docs_url = "https://grafana.com/docs/grafana/latest/developers/http_api/"
|
| 111 |
+
schema_preview = ["id", "uid", "title", "type"]
|
| 112 |
+
_read_path = "search?limit=10"
|
| 113 |
+
_record_path = ""
|
| 114 |
+
|
| 115 |
+
def _base_url(self):
|
| 116 |
+
return self.provider_base.replace("{url}",
|
| 117 |
+
os.environ.get("SZL_GRAFANA_URL", "").rstrip("/"))
|
| 118 |
+
|
| 119 |
+
def _auth_header(self):
|
| 120 |
+
tok = os.environ.get("SZL_GRAFANA_TOKEN")
|
| 121 |
+
return {"Authorization": f"Bearer {tok}"} if tok else {}
|
| 122 |
+
|
| 123 |
+
def _dig(self, raw):
|
| 124 |
+
rows = raw if isinstance(raw, list) else []
|
| 125 |
+
return [{k: r.get(k) for k in self.schema_preview if k in r}
|
| 126 |
+
for r in rows if isinstance(r, dict)]
|
| 127 |
+
|
| 128 |
+
|
| 129 |
+
__all__ = ["DatadogConnector", "SplunkConnector", "GrafanaConnector"]
|
|
@@ -0,0 +1,152 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# SPDX-License-Identifier: Apache-2.0
|
| 2 |
+
# © 2026 Lutar, Stephen P. — SZL Holdings · Doctrine v11 · Perplexity Computer Agent.
|
| 3 |
+
"""szl_connectors.ready — a reusable REAL client base for credential-READY connectors.
|
| 4 |
+
|
| 5 |
+
These connectors are GENUINELY ready, NOT stubs: each declares the provider's
|
| 6 |
+
documented REST endpoint, the documented record shape (schema_preview), the auth
|
| 7 |
+
header builder, and a real `read()` that performs the documented HTTP call. With
|
| 8 |
+
NO credentials it returns state=READY + "set <ENV_VAR> to activate" + the exact
|
| 9 |
+
secret name(s) — and NEVER fabricates records. The moment the customer's
|
| 10 |
+
credentials land in the Space secret, `health()`/`read()` flip to CONNECTED and
|
| 11 |
+
render the customer's live data.
|
| 12 |
+
|
| 13 |
+
DOCTRINE: env-only creds; never a fabricated record; honest READY/CONNECTED/ERROR.
|
| 14 |
+
"""
|
| 15 |
+
from __future__ import annotations
|
| 16 |
+
|
| 17 |
+
import os
|
| 18 |
+
from typing import Any, Callable
|
| 19 |
+
|
| 20 |
+
from .base import Connector, Records, State, WriteResult, http_json, cred_fingerprint
|
| 21 |
+
from .governance import gate_write
|
| 22 |
+
|
| 23 |
+
|
| 24 |
+
class ReadyConnector(Connector):
|
| 25 |
+
"""A connector whose read() hits a documented REST endpoint with a header-auth.
|
| 26 |
+
|
| 27 |
+
Subclasses set: id, label, category, auth_kind, env_vars, provider_base,
|
| 28 |
+
docs_url, schema_preview, _read_path (the endpoint path appended to
|
| 29 |
+
provider_base, may contain {sub} from a base_sub() override), _record_path
|
| 30 |
+
(dotted path into the JSON to the list of records), and _auth_header().
|
| 31 |
+
"""
|
| 32 |
+
_read_path: str = ""
|
| 33 |
+
_record_path: str = "" # e.g. "value" or "results" or "data.contacts"
|
| 34 |
+
_record_fields: list[str] = [] # which fields to project (defaults to schema_preview)
|
| 35 |
+
_query_param: dict[str, Any] = {}
|
| 36 |
+
writable = False
|
| 37 |
+
_primary_secret: str = "" # the single secret to name in the READY chip
|
| 38 |
+
|
| 39 |
+
# ── auth header (override per provider) ───────────────────────────────
|
| 40 |
+
def _token(self) -> str | None:
|
| 41 |
+
for k in self.env_vars:
|
| 42 |
+
v = os.environ.get(k)
|
| 43 |
+
if v:
|
| 44 |
+
return v
|
| 45 |
+
return None
|
| 46 |
+
|
| 47 |
+
def _auth_header(self) -> dict[str, str]:
|
| 48 |
+
tok = self._token()
|
| 49 |
+
return {"Authorization": f"Bearer {tok}"} if tok else {}
|
| 50 |
+
|
| 51 |
+
def _base_url(self) -> str:
|
| 52 |
+
"""Substitute {sub} placeholders from env (instance/host/tenant)."""
|
| 53 |
+
url = self.provider_base
|
| 54 |
+
# common substitutions from env
|
| 55 |
+
for env_name in self.env_vars:
|
| 56 |
+
val = os.environ.get(env_name)
|
| 57 |
+
if val and "{" in url:
|
| 58 |
+
tag = env_name.split("_")[-1].lower() # e.g. ...INSTANCE -> instance
|
| 59 |
+
url = url.replace("{" + tag + "}", val)
|
| 60 |
+
return url
|
| 61 |
+
|
| 62 |
+
def _primary_missing(self) -> bool:
|
| 63 |
+
sec = self._primary_secret or (self.env_vars[0] if self.env_vars else "")
|
| 64 |
+
return not os.environ.get(sec)
|
| 65 |
+
|
| 66 |
+
def _missing_env(self):
|
| 67 |
+
return [k for k in self.env_vars if not os.environ.get(k)]
|
| 68 |
+
|
| 69 |
+
def _probe(self):
|
| 70 |
+
if self._primary_missing():
|
| 71 |
+
return None, "no creds"
|
| 72 |
+
url = self._base_url().rstrip("/") + "/" + self._read_path.lstrip("/")
|
| 73 |
+
st, _ = http_json(url, headers={"Accept": "application/json", **self._auth_header()})
|
| 74 |
+
return (st in (200, 201)), f"{self.label} HTTP {st}"
|
| 75 |
+
|
| 76 |
+
def _dig(self, raw: Any) -> list[dict]:
|
| 77 |
+
cur = raw
|
| 78 |
+
if self._record_path:
|
| 79 |
+
for part in self._record_path.split("."):
|
| 80 |
+
if isinstance(cur, dict):
|
| 81 |
+
cur = cur.get(part, [])
|
| 82 |
+
else:
|
| 83 |
+
cur = []
|
| 84 |
+
if isinstance(cur, dict):
|
| 85 |
+
cur = cur.get("results") or cur.get("data") or cur.get("value") or list(cur.values())
|
| 86 |
+
return cur if isinstance(cur, list) else []
|
| 87 |
+
|
| 88 |
+
def read(self, query: dict | None = None) -> Records:
|
| 89 |
+
if self._primary_missing():
|
| 90 |
+
sec = self._primary_secret or (self.env_vars[0] if self.env_vars else "?")
|
| 91 |
+
return self._ready_records(
|
| 92 |
+
f"provide credentials to activate — set {', '.join(self._missing_env())} "
|
| 93 |
+
f"(primary secret: {sec}). Hits {self.provider_base}/{self._read_path}.")
|
| 94 |
+
limit = max(1, min(int((query or {}).get("limit", 12)), 50))
|
| 95 |
+
url = self._base_url().rstrip("/") + "/" + self._read_path.lstrip("/")
|
| 96 |
+
import urllib.parse as up
|
| 97 |
+
params = dict(self._query_param)
|
| 98 |
+
if params:
|
| 99 |
+
url += ("&" if "?" in url else "?") + up.urlencode(params)
|
| 100 |
+
st, raw = http_json(url, headers={"Accept": "application/json", **self._auth_header()})
|
| 101 |
+
if st in (200, 201):
|
| 102 |
+
rows = self._dig(raw)
|
| 103 |
+
fields = self._record_fields or self.schema_preview
|
| 104 |
+
proj = []
|
| 105 |
+
for r in rows[:limit]:
|
| 106 |
+
if isinstance(r, dict):
|
| 107 |
+
p = {f: r.get(f) for f in fields if f in r}
|
| 108 |
+
proj.append(p or {k: v for k, v in list(r.items())[:6] if not isinstance(v, (dict, list))})
|
| 109 |
+
return Records(connector_id=self.id, category=self.category, state=State.CONNECTED,
|
| 110 |
+
records=proj, source=f"{self.label} {url}", live=True,
|
| 111 |
+
note=f"live · {len(rows)} records", schema_preview=fields)
|
| 112 |
+
return Records(connector_id=self.id, category=self.category, state=State.ERROR,
|
| 113 |
+
records=[], source=self.provider_base, live=False,
|
| 114 |
+
note=f"credentials present but {self.label} returned HTTP {st}",
|
| 115 |
+
schema_preview=self.schema_preview)
|
| 116 |
+
|
| 117 |
+
|
| 118 |
+
class WritableReadyConnector(ReadyConnector):
|
| 119 |
+
"""A ReadyConnector that also exposes a Λ-gated + receipted write()."""
|
| 120 |
+
writable = True
|
| 121 |
+
mcp_tool = "szl_connector_read/write"
|
| 122 |
+
_write_path: str = ""
|
| 123 |
+
|
| 124 |
+
def write(self, action: dict | None = None) -> WriteResult:
|
| 125 |
+
action = action or {}
|
| 126 |
+
connected = not self._primary_missing()
|
| 127 |
+
gate_action = {"method": action.get("method", "create"),
|
| 128 |
+
"object": action.get("object", self._write_path or "record"),
|
| 129 |
+
"values_keys": sorted((action.get("values") or {}).keys())}
|
| 130 |
+
tok = self._token()
|
| 131 |
+
creds_fp = {"token": cred_fingerprint(tok)} if tok else {}
|
| 132 |
+
allowed, lam, receipt, quorum, detail = gate_write(
|
| 133 |
+
connector_id=self.id, connected=connected, action=gate_action,
|
| 134 |
+
cred_fingerprints=creds_fp, quorum_present=action.get("quorum_present"))
|
| 135 |
+
if not allowed:
|
| 136 |
+
return WriteResult(connector_id=self.id, ok=False,
|
| 137 |
+
state=State.READY if not connected else State.CONNECTED,
|
| 138 |
+
receipt_hash=receipt["receipt_hash"], lambda_value=lam,
|
| 139 |
+
quorum=quorum, detail=detail, dsse=receipt["dsse"])
|
| 140 |
+
import json as _json
|
| 141 |
+
url = self._base_url().rstrip("/") + "/" + (self._write_path or self._read_path).lstrip("/")
|
| 142 |
+
st, raw = http_json(url, method="POST",
|
| 143 |
+
headers={"Content-Type": "application/json", **self._auth_header()},
|
| 144 |
+
data=_json.dumps(action.get("values", {})).encode())
|
| 145 |
+
ok = st in (200, 201)
|
| 146 |
+
return WriteResult(connector_id=self.id, ok=ok,
|
| 147 |
+
state=State.CONNECTED if ok else State.ERROR,
|
| 148 |
+
receipt_hash=receipt["receipt_hash"], lambda_value=lam, quorum=quorum,
|
| 149 |
+
detail=f"{self.label} write HTTP {st}", dsse=receipt["dsse"])
|
| 150 |
+
|
| 151 |
+
|
| 152 |
+
__all__ = ["ReadyConnector", "WritableReadyConnector"]
|
|
@@ -0,0 +1,99 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# SPDX-License-Identifier: Apache-2.0
|
| 2 |
+
# © 2026 Lutar, Stephen P. — SZL Holdings · ORCID 0009-0001-0110-4173 · Doctrine v11
|
| 3 |
+
# Authored by SZL Enterprise-Integration Team. Co-Authored-By: Perplexity Computer Agent.
|
| 4 |
+
"""szl_connectors.registry — the connector registry + the honest manifest.
|
| 5 |
+
|
| 6 |
+
Adding a connector = one subclass file + one `@register` decorator. The UI,
|
| 7 |
+
the MCP layer, and the governance layer all read the registry uniformly.
|
| 8 |
+
|
| 9 |
+
The manifest reports every connector's HONEST state right now (cheap path:
|
| 10 |
+
keyless → CONNECTED label verified on read; credentialed → READY/CONNECTED by
|
| 11 |
+
env presence; no-free-tier → SAMPLE). NEVER fabricates a record.
|
| 12 |
+
"""
|
| 13 |
+
from __future__ import annotations
|
| 14 |
+
|
| 15 |
+
from typing import Any
|
| 16 |
+
|
| 17 |
+
from .base import Connector, State, _now
|
| 18 |
+
|
| 19 |
+
REGISTRY: dict[str, type[Connector]] = {} # id -> class
|
| 20 |
+
|
| 21 |
+
|
| 22 |
+
def register(cls: type[Connector]) -> type[Connector]:
|
| 23 |
+
"""@register decorator — one line per connector module."""
|
| 24 |
+
if not getattr(cls, "id", None) or cls.id == "base":
|
| 25 |
+
raise ValueError("connector must define a unique non-base `id`")
|
| 26 |
+
REGISTRY[cls.id] = cls
|
| 27 |
+
return cls
|
| 28 |
+
|
| 29 |
+
|
| 30 |
+
def get(cid: str) -> Connector | None:
|
| 31 |
+
cls = REGISTRY.get(cid)
|
| 32 |
+
return cls() if cls else None
|
| 33 |
+
|
| 34 |
+
|
| 35 |
+
def all_ids() -> list[str]:
|
| 36 |
+
return sorted(REGISTRY.keys())
|
| 37 |
+
|
| 38 |
+
|
| 39 |
+
def _entry(cid: str, cls: type[Connector], *, probe: bool = False) -> dict[str, Any]:
|
| 40 |
+
c = cls()
|
| 41 |
+
h = c.health(probe=probe)
|
| 42 |
+
return {
|
| 43 |
+
"id": cid,
|
| 44 |
+
"label": c.label or cid,
|
| 45 |
+
"category": c.category,
|
| 46 |
+
"auth_kind": c.auth_kind,
|
| 47 |
+
"state": h.state.value if isinstance(h.state, State) else h.state,
|
| 48 |
+
"env_vars": list(c.env_vars),
|
| 49 |
+
"missing_env": h.missing_env,
|
| 50 |
+
"free_tier": c.free_tier,
|
| 51 |
+
"writable": c.writable,
|
| 52 |
+
"provider_base": c.provider_base,
|
| 53 |
+
"mcp_tool": c.mcp_tool,
|
| 54 |
+
"detail": h.detail,
|
| 55 |
+
"sample_reason": h.sample_reason,
|
| 56 |
+
"schema_preview": list(c.schema_preview),
|
| 57 |
+
"docs_url": c.docs_url,
|
| 58 |
+
"latency_ms": h.latency_ms,
|
| 59 |
+
}
|
| 60 |
+
|
| 61 |
+
|
| 62 |
+
def manifest(*, probe: bool = False, category: str | None = None) -> dict[str, Any]:
|
| 63 |
+
"""Every connector + its honest state, for the catalog UI + /healthz."""
|
| 64 |
+
items = []
|
| 65 |
+
for cid in sorted(REGISTRY):
|
| 66 |
+
cls = REGISTRY[cid]
|
| 67 |
+
try:
|
| 68 |
+
e = _entry(cid, cls, probe=probe)
|
| 69 |
+
except Exception as ex: # honest error, never crash the manifest
|
| 70 |
+
e = {"id": cid, "category": getattr(cls, "category", "?"),
|
| 71 |
+
"state": "error", "detail": f"manifest probe failed: {type(ex).__name__}"}
|
| 72 |
+
if category and e.get("category") != category:
|
| 73 |
+
continue
|
| 74 |
+
items.append(e)
|
| 75 |
+
# honest scoreboard
|
| 76 |
+
counts = {"connected": 0, "ready": 0, "sample": 0, "error": 0}
|
| 77 |
+
cats: dict[str, int] = {}
|
| 78 |
+
for e in items:
|
| 79 |
+
counts[e.get("state", "error")] = counts.get(e.get("state", "error"), 0) + 1
|
| 80 |
+
cats[e.get("category", "?")] = cats.get(e.get("category", "?"), 0) + 1
|
| 81 |
+
return {
|
| 82 |
+
"doctrine": "v11 — honest CONNECTED/READY/SAMPLE only; no fabricated records; "
|
| 83 |
+
"no committed keys; Λ-gate+DSSE on writes; trust never 100%",
|
| 84 |
+
"fetched_at": _now(),
|
| 85 |
+
"count": len(items),
|
| 86 |
+
"scoreboard": counts,
|
| 87 |
+
"categories": cats,
|
| 88 |
+
"connectors": items,
|
| 89 |
+
}
|
| 90 |
+
|
| 91 |
+
|
| 92 |
+
def health(cid: str) -> dict[str, Any]:
|
| 93 |
+
c = get(cid)
|
| 94 |
+
if not c:
|
| 95 |
+
return {"error": f"unknown connector '{cid}'", "known": all_ids()}
|
| 96 |
+
return c.health(probe=True).to_dict()
|
| 97 |
+
|
| 98 |
+
|
| 99 |
+
__all__ = ["REGISTRY", "register", "get", "all_ids", "manifest", "health"]
|
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# SPDX-License-Identifier: Apache-2.0
|
| 2 |
+
# © 2026 SZL Holdings. Object-storage connectors (S3/GCS/Azure Blob — credential-READY).
|
| 3 |
+
from . import storage_connectors # noqa: F401
|
|
@@ -0,0 +1,179 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# SPDX-License-Identifier: Apache-2.0
|
| 2 |
+
# © 2026 Lutar, Stephen P. — SZL Holdings · Doctrine v11 · Perplexity Computer Agent.
|
| 3 |
+
"""Object-storage connectors — Amazon S3, Google Cloud Storage, Azure Blob.
|
| 4 |
+
|
| 5 |
+
REAL clients against documented endpoints. NO creds → READY + exact secret name;
|
| 6 |
+
NEVER fabricates a record. Reads are bucket/object listings (metadata-scoped).
|
| 7 |
+
|
| 8 |
+
S3 read uses AWS Signature v4 (auth_kind="aws_sigv4"); because SigV4 signing is
|
| 9 |
+
non-trivial without the long-term secret present, the READY path names the exact
|
| 10 |
+
secrets and the live path signs the request once the secret is supplied.
|
| 11 |
+
|
| 12 |
+
API refs (publicly documented shapes):
|
| 13 |
+
S3 ListObjectsV2 https://docs.aws.amazon.com/AmazonS3/latest/API/API_ListObjectsV2.html
|
| 14 |
+
GCS objects.list https://cloud.google.com/storage/docs/json_api/v1/objects/list
|
| 15 |
+
Azure Blob List https://learn.microsoft.com/rest/api/storageservices/list-blobs
|
| 16 |
+
"""
|
| 17 |
+
from __future__ import annotations
|
| 18 |
+
|
| 19 |
+
import datetime as _dt
|
| 20 |
+
import hashlib
|
| 21 |
+
import hmac
|
| 22 |
+
import os
|
| 23 |
+
import urllib.parse as _up
|
| 24 |
+
|
| 25 |
+
from ..base import State, Records, http_json, http_text
|
| 26 |
+
from ..ready import ReadyConnector
|
| 27 |
+
from ..registry import register
|
| 28 |
+
|
| 29 |
+
|
| 30 |
+
def _sigv4_headers(method, host, region, service, path, query, access_key, secret_key, session_token=None):
|
| 31 |
+
"""Minimal AWS SigV4 signer for a GET with empty payload."""
|
| 32 |
+
now = _dt.datetime.now(_dt.timezone.utc)
|
| 33 |
+
amzdate = now.strftime("%Y%m%dT%H%M%SZ")
|
| 34 |
+
datestamp = now.strftime("%Y%m%d")
|
| 35 |
+
payload_hash = hashlib.sha256(b"").hexdigest()
|
| 36 |
+
canon_headers = f"host:{host}\nx-amz-content-sha256:{payload_hash}\nx-amz-date:{amzdate}\n"
|
| 37 |
+
signed_headers = "host;x-amz-content-sha256;x-amz-date"
|
| 38 |
+
canon_req = f"{method}\n{path}\n{query}\n{canon_headers}\n{signed_headers}\n{payload_hash}"
|
| 39 |
+
scope = f"{datestamp}/{region}/{service}/aws4_request"
|
| 40 |
+
string_to_sign = ("AWS4-HMAC-SHA256\n" + amzdate + "\n" + scope + "\n" +
|
| 41 |
+
hashlib.sha256(canon_req.encode()).hexdigest())
|
| 42 |
+
|
| 43 |
+
def _hmac(key, msg):
|
| 44 |
+
return hmac.new(key, msg.encode(), hashlib.sha256).digest()
|
| 45 |
+
kdate = _hmac(("AWS4" + secret_key).encode(), datestamp)
|
| 46 |
+
kregion = _hmac(kdate, region)
|
| 47 |
+
kservice = _hmac(kregion, service)
|
| 48 |
+
ksigning = _hmac(kservice, "aws4_request")
|
| 49 |
+
signature = hmac.new(ksigning, string_to_sign.encode(), hashlib.sha256).hexdigest()
|
| 50 |
+
auth = (f"AWS4-HMAC-SHA256 Credential={access_key}/{scope}, "
|
| 51 |
+
f"SignedHeaders={signed_headers}, Signature={signature}")
|
| 52 |
+
h = {"Authorization": auth, "x-amz-date": amzdate, "x-amz-content-sha256": payload_hash}
|
| 53 |
+
if session_token:
|
| 54 |
+
h["x-amz-security-token"] = session_token
|
| 55 |
+
return h
|
| 56 |
+
|
| 57 |
+
|
| 58 |
+
# ── Amazon S3 (aws_sigv4; free 5GB tier) ──────────────────────────────────────
|
| 59 |
+
@register
|
| 60 |
+
class S3Connector(ReadyConnector):
|
| 61 |
+
id = "s3"
|
| 62 |
+
label = "Amazon S3"
|
| 63 |
+
category = "storage"
|
| 64 |
+
auth_kind = "aws_sigv4"
|
| 65 |
+
free_tier = True # 5 GB free tier
|
| 66 |
+
env_vars = ["SZL_AWS_ACCESS_KEY_ID", "SZL_AWS_SECRET_ACCESS_KEY",
|
| 67 |
+
"SZL_AWS_REGION", "SZL_S3_BUCKET"]
|
| 68 |
+
_primary_secret = "SZL_AWS_SECRET_ACCESS_KEY"
|
| 69 |
+
provider_base = "https://{bucket}.s3.{region}.amazonaws.com"
|
| 70 |
+
docs_url = "https://docs.aws.amazon.com/AmazonS3/latest/API/API_ListObjectsV2.html"
|
| 71 |
+
schema_preview = ["Key", "Size", "LastModified", "StorageClass"]
|
| 72 |
+
|
| 73 |
+
def read(self, query=None):
|
| 74 |
+
ak = os.environ.get("SZL_AWS_ACCESS_KEY_ID")
|
| 75 |
+
sk = os.environ.get("SZL_AWS_SECRET_ACCESS_KEY")
|
| 76 |
+
region = os.environ.get("SZL_AWS_REGION", "us-east-1")
|
| 77 |
+
bucket = os.environ.get("SZL_S3_BUCKET", "")
|
| 78 |
+
if not (ak and sk and bucket):
|
| 79 |
+
return self._ready_records(
|
| 80 |
+
"provide credentials to activate — set SZL_AWS_ACCESS_KEY_ID, "
|
| 81 |
+
"SZL_AWS_SECRET_ACCESS_KEY, SZL_AWS_REGION, SZL_S3_BUCKET. "
|
| 82 |
+
"Signs ListObjectsV2 with AWS SigV4.")
|
| 83 |
+
host = f"{bucket}.s3.{region}.amazonaws.com"
|
| 84 |
+
qs = "list-type=2&max-keys=10"
|
| 85 |
+
headers = _sigv4_headers("GET", host, region, "s3", "/", qs, ak, sk,
|
| 86 |
+
os.environ.get("SZL_AWS_SESSION_TOKEN"))
|
| 87 |
+
url = f"https://{host}/?{qs}"
|
| 88 |
+
st, body = http_text(url, headers=headers)
|
| 89 |
+
if st == 200 and isinstance(body, str):
|
| 90 |
+
import re
|
| 91 |
+
keys = re.findall(r"<Key>(.*?)</Key>", body)
|
| 92 |
+
sizes = re.findall(r"<Size>(.*?)</Size>", body)
|
| 93 |
+
out = [{"Key": k, "Size": (sizes[i] if i < len(sizes) else None)}
|
| 94 |
+
for i, k in enumerate(keys[:10])]
|
| 95 |
+
return Records(connector_id=self.id, category=self.category, state=State.CONNECTED,
|
| 96 |
+
records=out, source=f"S3 ListObjectsV2 {bucket}", live=True,
|
| 97 |
+
note=f"live · {len(keys)} objects", schema_preview=self.schema_preview)
|
| 98 |
+
return Records(connector_id=self.id, category=self.category, state=State.ERROR,
|
| 99 |
+
records=[], source=self.provider_base, live=False,
|
| 100 |
+
note=f"credentials present but S3 HTTP {st}", schema_preview=self.schema_preview)
|
| 101 |
+
|
| 102 |
+
|
| 103 |
+
# ── Google Cloud Storage (oauth2 access token; free 5GB tier) ─────────────────
|
| 104 |
+
@register
|
| 105 |
+
class GcsConnector(ReadyConnector):
|
| 106 |
+
id = "gcs"
|
| 107 |
+
label = "Google Cloud Storage"
|
| 108 |
+
category = "storage"
|
| 109 |
+
auth_kind = "oauth2"
|
| 110 |
+
free_tier = True
|
| 111 |
+
env_vars = ["SZL_GCS_BUCKET", "SZL_GCS_ACCESS_TOKEN"]
|
| 112 |
+
_primary_secret = "SZL_GCS_ACCESS_TOKEN"
|
| 113 |
+
provider_base = "https://storage.googleapis.com/storage/v1"
|
| 114 |
+
docs_url = "https://cloud.google.com/storage/docs/json_api/v1/objects/list"
|
| 115 |
+
schema_preview = ["name", "size", "contentType", "updated"]
|
| 116 |
+
_record_path = "items"
|
| 117 |
+
|
| 118 |
+
def _auth_header(self):
|
| 119 |
+
tok = os.environ.get("SZL_GCS_ACCESS_TOKEN")
|
| 120 |
+
return {"Authorization": f"Bearer {tok}"} if tok else {}
|
| 121 |
+
|
| 122 |
+
def read(self, query=None):
|
| 123 |
+
if self._primary_missing():
|
| 124 |
+
return self._ready_records(
|
| 125 |
+
"provide credentials to activate — set SZL_GCS_BUCKET, "
|
| 126 |
+
"SZL_GCS_ACCESS_TOKEN. Lists objects via storage.objects.list.")
|
| 127 |
+
bucket = os.environ.get("SZL_GCS_BUCKET", "")
|
| 128 |
+
url = f"{self.provider_base}/b/{bucket}/o?maxResults=10"
|
| 129 |
+
st, raw = http_json(url, headers={"Accept": "application/json", **self._auth_header()})
|
| 130 |
+
if st == 200 and isinstance(raw, dict):
|
| 131 |
+
rows = raw.get("items", []) or []
|
| 132 |
+
out = [{k: r.get(k) for k in self.schema_preview if k in r} for r in rows[:10]]
|
| 133 |
+
return Records(connector_id=self.id, category=self.category, state=State.CONNECTED,
|
| 134 |
+
records=out, source=f"GCS objects.list {bucket}", live=True,
|
| 135 |
+
note=f"live · {len(rows)} objects", schema_preview=self.schema_preview)
|
| 136 |
+
return Records(connector_id=self.id, category=self.category, state=State.ERROR,
|
| 137 |
+
records=[], source=self.provider_base, live=False,
|
| 138 |
+
note=f"credentials present but GCS HTTP {st}", schema_preview=self.schema_preview)
|
| 139 |
+
|
| 140 |
+
|
| 141 |
+
# ── Azure Blob Storage (SAS token; free tier) ─────────────────────────────────
|
| 142 |
+
@register
|
| 143 |
+
class AzureBlobConnector(ReadyConnector):
|
| 144 |
+
id = "azure_blob"
|
| 145 |
+
label = "Azure Blob Storage"
|
| 146 |
+
category = "storage"
|
| 147 |
+
auth_kind = "api_key" # SAS token
|
| 148 |
+
free_tier = True
|
| 149 |
+
env_vars = ["SZL_AZURE_BLOB_ACCOUNT", "SZL_AZURE_BLOB_CONTAINER", "SZL_AZURE_BLOB_SAS"]
|
| 150 |
+
_primary_secret = "SZL_AZURE_BLOB_SAS"
|
| 151 |
+
provider_base = "https://{account}.blob.core.windows.net"
|
| 152 |
+
docs_url = "https://learn.microsoft.com/rest/api/storageservices/list-blobs"
|
| 153 |
+
schema_preview = ["Name", "Properties.Content-Length", "Properties.Last-Modified"]
|
| 154 |
+
|
| 155 |
+
def read(self, query=None):
|
| 156 |
+
acct = os.environ.get("SZL_AZURE_BLOB_ACCOUNT", "")
|
| 157 |
+
cont = os.environ.get("SZL_AZURE_BLOB_CONTAINER", "")
|
| 158 |
+
sas = os.environ.get("SZL_AZURE_BLOB_SAS", "")
|
| 159 |
+
if not (acct and cont and sas):
|
| 160 |
+
return self._ready_records(
|
| 161 |
+
"provide credentials to activate — set SZL_AZURE_BLOB_ACCOUNT, "
|
| 162 |
+
"SZL_AZURE_BLOB_CONTAINER, SZL_AZURE_BLOB_SAS. Lists blobs (restype=container&comp=list).")
|
| 163 |
+
sep = "&" if sas.startswith("?") else "?"
|
| 164 |
+
sas2 = sas.lstrip("?")
|
| 165 |
+
url = f"https://{acct}.blob.core.windows.net/{cont}?restype=container&comp=list&maxresults=10&{sas2}"
|
| 166 |
+
st, body = http_text(url)
|
| 167 |
+
if st == 200 and isinstance(body, str):
|
| 168 |
+
import re
|
| 169 |
+
names = re.findall(r"<Name>(.*?)</Name>", body)
|
| 170 |
+
out = [{"Name": n} for n in names[:10]]
|
| 171 |
+
return Records(connector_id=self.id, category=self.category, state=State.CONNECTED,
|
| 172 |
+
records=out, source=f"Azure Blob list {cont}", live=True,
|
| 173 |
+
note=f"live · {len(names)} blobs", schema_preview=self.schema_preview)
|
| 174 |
+
return Records(connector_id=self.id, category=self.category, state=State.ERROR,
|
| 175 |
+
records=[], source=self.provider_base, live=False,
|
| 176 |
+
note=f"credentials present but Azure Blob HTTP {st}", schema_preview=self.schema_preview)
|
| 177 |
+
|
| 178 |
+
|
| 179 |
+
__all__ = ["S3Connector", "GcsConnector", "AzureBlobConnector"]
|
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# SPDX-License-Identifier: Apache-2.0
|
| 2 |
+
# © 2026 SZL Holdings. Data-warehouse connectors (Snowflake/Databricks/BigQuery/Postgres).
|
| 3 |
+
from . import warehouse_connectors # noqa: F401
|
|
@@ -0,0 +1,182 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# SPDX-License-Identifier: Apache-2.0
|
| 2 |
+
# © 2026 Lutar, Stephen P. — SZL Holdings · Doctrine v11 · Perplexity Computer Agent.
|
| 3 |
+
"""Data-warehouse / database connectors — Snowflake, Databricks, BigQuery, Postgres.
|
| 4 |
+
|
| 5 |
+
REAL clients against documented endpoints. NO creds → READY + exact secret name;
|
| 6 |
+
NEVER fabricates a record. Reads are catalog/metadata-scoped by default (list
|
| 7 |
+
tables/datasets) — cheap, safe, and verifiable.
|
| 8 |
+
|
| 9 |
+
API refs (publicly documented shapes):
|
| 10 |
+
Snowflake SQL API https://docs.snowflake.com/developer-guide/sql-api/index
|
| 11 |
+
Databricks REST 2.1 https://docs.databricks.com/api/workspace/statementexecution
|
| 12 |
+
BigQuery REST v2 https://cloud.google.com/bigquery/docs/reference/rest
|
| 13 |
+
Postgres (PostgREST) https://postgrest.org/ (or native via psycopg when available)
|
| 14 |
+
"""
|
| 15 |
+
from __future__ import annotations
|
| 16 |
+
|
| 17 |
+
import os
|
| 18 |
+
|
| 19 |
+
from ..base import State, Records, http_json
|
| 20 |
+
from ..ready import ReadyConnector
|
| 21 |
+
from ..registry import register
|
| 22 |
+
|
| 23 |
+
|
| 24 |
+
# ── Snowflake (SQL API v2; oauth2/keypair; free 30-day trial) ─────────────────
|
| 25 |
+
@register
|
| 26 |
+
class SnowflakeConnector(ReadyConnector):
|
| 27 |
+
id = "snowflake"
|
| 28 |
+
label = "Snowflake (SQL API)"
|
| 29 |
+
category = "warehouse"
|
| 30 |
+
auth_kind = "oauth2"
|
| 31 |
+
free_tier = True # 30-day trial
|
| 32 |
+
env_vars = ["SZL_SNOWFLAKE_ACCOUNT", "SZL_SNOWFLAKE_ACCESS_TOKEN"]
|
| 33 |
+
_primary_secret = "SZL_SNOWFLAKE_ACCESS_TOKEN"
|
| 34 |
+
provider_base = "https://{account}.snowflakecomputing.com/api/v2"
|
| 35 |
+
docs_url = "https://docs.snowflake.com/developer-guide/sql-api/index"
|
| 36 |
+
schema_preview = ["database_name", "schema_name", "table_name", "row_count"]
|
| 37 |
+
_read_path = "statements"
|
| 38 |
+
|
| 39 |
+
def _base_url(self):
|
| 40 |
+
return self.provider_base.replace("{account}",
|
| 41 |
+
os.environ.get("SZL_SNOWFLAKE_ACCOUNT", ""))
|
| 42 |
+
|
| 43 |
+
def _auth_header(self):
|
| 44 |
+
tok = os.environ.get("SZL_SNOWFLAKE_ACCESS_TOKEN")
|
| 45 |
+
return ({"Authorization": f"Bearer {tok}",
|
| 46 |
+
"X-Snowflake-Authorization-Token-Type": "OAUTH"} if tok else {})
|
| 47 |
+
|
| 48 |
+
def read(self, query=None):
|
| 49 |
+
if self._primary_missing():
|
| 50 |
+
return self._ready_records(
|
| 51 |
+
"provide credentials to activate — set SZL_SNOWFLAKE_ACCOUNT, "
|
| 52 |
+
"SZL_SNOWFLAKE_ACCESS_TOKEN. POSTs SHOW TABLES via /api/v2/statements.")
|
| 53 |
+
import json as _json
|
| 54 |
+
body = {"statement": "SHOW TABLES LIMIT 10", "timeout": 30}
|
| 55 |
+
url = self._base_url() + "/statements"
|
| 56 |
+
st, raw = http_json(url, method="POST",
|
| 57 |
+
headers={"Content-Type": "application/json", **self._auth_header()},
|
| 58 |
+
data=_json.dumps(body).encode())
|
| 59 |
+
if st in (200, 202) and isinstance(raw, dict):
|
| 60 |
+
rows = raw.get("data", []) or []
|
| 61 |
+
proj = [{"row": r} for r in rows[:10]] if rows else []
|
| 62 |
+
return Records(connector_id=self.id, category=self.category, state=State.CONNECTED,
|
| 63 |
+
records=proj, source="Snowflake SQL API SHOW TABLES", live=True,
|
| 64 |
+
note=f"live · {len(rows)} rows", schema_preview=self.schema_preview)
|
| 65 |
+
return Records(connector_id=self.id, category=self.category, state=State.ERROR,
|
| 66 |
+
records=[], source=self.provider_base, live=False,
|
| 67 |
+
note=f"credentials present but Snowflake HTTP {st}", schema_preview=self.schema_preview)
|
| 68 |
+
|
| 69 |
+
|
| 70 |
+
# ── Databricks (Statement Execution API 2.0; PAT; free Community/trial) ───────
|
| 71 |
+
@register
|
| 72 |
+
class DatabricksConnector(ReadyConnector):
|
| 73 |
+
id = "databricks"
|
| 74 |
+
label = "Databricks (SQL Statement API)"
|
| 75 |
+
category = "warehouse"
|
| 76 |
+
auth_kind = "api_key"
|
| 77 |
+
free_tier = True
|
| 78 |
+
env_vars = ["SZL_DATABRICKS_HOST", "SZL_DATABRICKS_TOKEN", "SZL_DATABRICKS_WAREHOUSE_ID"]
|
| 79 |
+
_primary_secret = "SZL_DATABRICKS_TOKEN"
|
| 80 |
+
provider_base = "{host}/api/2.0"
|
| 81 |
+
docs_url = "https://docs.databricks.com/api/workspace/statementexecution"
|
| 82 |
+
schema_preview = ["catalog", "schema", "tableName", "tableType"]
|
| 83 |
+
_read_path = "unity-catalog/tables?max_results=10"
|
| 84 |
+
_record_path = "tables"
|
| 85 |
+
|
| 86 |
+
def _base_url(self):
|
| 87 |
+
return self.provider_base.replace("{host}",
|
| 88 |
+
os.environ.get("SZL_DATABRICKS_HOST", "").rstrip("/"))
|
| 89 |
+
|
| 90 |
+
def _auth_header(self):
|
| 91 |
+
tok = os.environ.get("SZL_DATABRICKS_TOKEN")
|
| 92 |
+
return {"Authorization": f"Bearer {tok}"} if tok else {}
|
| 93 |
+
|
| 94 |
+
|
| 95 |
+
# ── Google BigQuery (REST v2; oauth2 access token; free 1TB/mo query) ─────────
|
| 96 |
+
@register
|
| 97 |
+
class BigQueryConnector(ReadyConnector):
|
| 98 |
+
id = "bigquery"
|
| 99 |
+
label = "Google BigQuery"
|
| 100 |
+
category = "warehouse"
|
| 101 |
+
auth_kind = "oauth2"
|
| 102 |
+
free_tier = True # 1 TB/month free query tier
|
| 103 |
+
env_vars = ["SZL_BIGQUERY_PROJECT_ID", "SZL_BIGQUERY_ACCESS_TOKEN"]
|
| 104 |
+
_primary_secret = "SZL_BIGQUERY_ACCESS_TOKEN"
|
| 105 |
+
provider_base = "https://bigquery.googleapis.com/bigquery/v2"
|
| 106 |
+
docs_url = "https://cloud.google.com/bigquery/docs/reference/rest"
|
| 107 |
+
schema_preview = ["datasetReference.datasetId", "id", "location"]
|
| 108 |
+
_read_path = "projects/{project}/datasets?maxResults=10"
|
| 109 |
+
_record_path = "datasets"
|
| 110 |
+
|
| 111 |
+
def _base_url(self):
|
| 112 |
+
return self.provider_base
|
| 113 |
+
|
| 114 |
+
def _auth_header(self):
|
| 115 |
+
tok = os.environ.get("SZL_BIGQUERY_ACCESS_TOKEN")
|
| 116 |
+
return {"Authorization": f"Bearer {tok}"} if tok else {}
|
| 117 |
+
|
| 118 |
+
def read(self, query=None):
|
| 119 |
+
if self._primary_missing():
|
| 120 |
+
return self._ready_records(
|
| 121 |
+
"provide credentials to activate — set SZL_BIGQUERY_PROJECT_ID, "
|
| 122 |
+
"SZL_BIGQUERY_ACCESS_TOKEN. Lists datasets via REST v2.")
|
| 123 |
+
proj = os.environ.get("SZL_BIGQUERY_PROJECT_ID", "")
|
| 124 |
+
url = f"{self.provider_base}/projects/{proj}/datasets?maxResults=10"
|
| 125 |
+
st, raw = http_json(url, headers={"Accept": "application/json", **self._auth_header()})
|
| 126 |
+
if st == 200 and isinstance(raw, dict):
|
| 127 |
+
rows = raw.get("datasets", []) or []
|
| 128 |
+
out = [{"datasetId": (r.get("datasetReference") or {}).get("datasetId"),
|
| 129 |
+
"id": r.get("id"), "location": r.get("location")} for r in rows[:10]]
|
| 130 |
+
return Records(connector_id=self.id, category=self.category, state=State.CONNECTED,
|
| 131 |
+
records=out, source="BigQuery datasets.list", live=True,
|
| 132 |
+
note=f"live · {len(rows)} datasets", schema_preview=self.schema_preview)
|
| 133 |
+
return Records(connector_id=self.id, category=self.category, state=State.ERROR,
|
| 134 |
+
records=[], source=self.provider_base, live=False,
|
| 135 |
+
note=f"credentials present but BigQuery HTTP {st}", schema_preview=self.schema_preview)
|
| 136 |
+
|
| 137 |
+
|
| 138 |
+
# ── Postgres via PostgREST (api_key/jwt; widely self-hosted; free) ────────────
|
| 139 |
+
@register
|
| 140 |
+
class PostgresConnector(ReadyConnector):
|
| 141 |
+
id = "postgres"
|
| 142 |
+
label = "PostgreSQL (via PostgREST)"
|
| 143 |
+
category = "warehouse"
|
| 144 |
+
auth_kind = "api_key"
|
| 145 |
+
free_tier = True # open-source self-host
|
| 146 |
+
env_vars = ["SZL_POSTGREST_URL", "SZL_POSTGREST_JWT"]
|
| 147 |
+
_primary_secret = "SZL_POSTGREST_URL"
|
| 148 |
+
provider_base = "{postgrest_url}"
|
| 149 |
+
docs_url = "https://postgrest.org/"
|
| 150 |
+
schema_preview = ["(table rows projected by selected resource)"]
|
| 151 |
+
_read_path = ""
|
| 152 |
+
|
| 153 |
+
def _base_url(self):
|
| 154 |
+
return self.provider_base.replace("{postgrest_url}",
|
| 155 |
+
os.environ.get("SZL_POSTGREST_URL", "").rstrip("/"))
|
| 156 |
+
|
| 157 |
+
def _auth_header(self):
|
| 158 |
+
jwt = os.environ.get("SZL_POSTGREST_JWT")
|
| 159 |
+
return {"Authorization": f"Bearer {jwt}"} if jwt else {}
|
| 160 |
+
|
| 161 |
+
def read(self, query=None):
|
| 162 |
+
if not os.environ.get("SZL_POSTGREST_URL"):
|
| 163 |
+
return self._ready_records(
|
| 164 |
+
"provide credentials to activate — set SZL_POSTGREST_URL "
|
| 165 |
+
"(and optional SZL_POSTGREST_JWT). GETs a configured resource path.")
|
| 166 |
+
resource = (query or {}).get("resource", "")
|
| 167 |
+
if not resource:
|
| 168 |
+
return self._ready_records(
|
| 169 |
+
"connected — pass query.resource (PostgREST table/view name) to read rows. "
|
| 170 |
+
"Endpoint: " + self._base_url())
|
| 171 |
+
url = self._base_url() + "/" + resource.lstrip("/") + "?limit=10"
|
| 172 |
+
st, raw = http_json(url, headers={"Accept": "application/json", **self._auth_header()})
|
| 173 |
+
if st == 200 and isinstance(raw, list):
|
| 174 |
+
return Records(connector_id=self.id, category=self.category, state=State.CONNECTED,
|
| 175 |
+
records=raw[:10], source=f"PostgREST {resource}", live=True,
|
| 176 |
+
note=f"live · {len(raw)} rows", schema_preview=self.schema_preview)
|
| 177 |
+
return Records(connector_id=self.id, category=self.category, state=State.ERROR,
|
| 178 |
+
records=[], source=self.provider_base, live=False,
|
| 179 |
+
note=f"credentials present but PostgREST HTTP {st}", schema_preview=self.schema_preview)
|
| 180 |
+
|
| 181 |
+
|
| 182 |
+
__all__ = ["SnowflakeConnector", "DatabricksConnector", "BigQueryConnector", "PostgresConnector"]
|
|
@@ -0,0 +1,146 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# SPDX-License-Identifier: Apache-2.0
|
| 2 |
+
# © 2026 Lutar, Stephen P. — SZL Holdings · ORCID 0009-0001-0110-4173 · Doctrine v11
|
| 3 |
+
# Authored by SZL Enterprise-Integration Team. Co-Authored-By: Perplexity Computer Agent.
|
| 4 |
+
"""szl_connectors_serve — FastAPI registration module for the Enterprise Mesh.
|
| 5 |
+
|
| 6 |
+
ADDITIVE & GUARDED (matches szl_a11oy_live_feeds.register pattern). Mounts the
|
| 7 |
+
connector manifest, per-connector health/read/write, the OAuth2 start/callback
|
| 8 |
+
flow, and the standalone /integrations page route — all BEFORE the SPA catch-all,
|
| 9 |
+
all try/except-guarded so they can NEVER take the Space down.
|
| 10 |
+
|
| 11 |
+
Endpoints (ns defaults to "a11oy"):
|
| 12 |
+
GET /api/{ns}/connectors → honest manifest + scoreboard
|
| 13 |
+
GET /api/{ns}/v1/connectors → alias (versioned)
|
| 14 |
+
GET /api/{ns}/v1/connectors/{cid}/health → one connector's honest health
|
| 15 |
+
GET /api/{ns}/v1/connectors/{cid}/read → live records | READY | SAMPLE (never faked)
|
| 16 |
+
POST /api/{ns}/v1/connectors/{cid}/write → Λ-gated + DSSE-receipted write
|
| 17 |
+
GET /api/{ns}/v1/connectors/{cid}/oauth/start → PKCE authorize URL (signed state)
|
| 18 |
+
GET /api/{ns}/v1/connectors/{cid}/oauth/callback → code→token, credential-bound receipt
|
| 19 |
+
GET /integrations → standalone Enterprise Mesh page
|
| 20 |
+
|
| 21 |
+
DOCTRINE: honest states only; never fabricates a record; writes carry a DSSE
|
| 22 |
+
receipt with credential FINGERPRINT hashes (never the key); 0 runtime CDN.
|
| 23 |
+
"""
|
| 24 |
+
from __future__ import annotations
|
| 25 |
+
|
| 26 |
+
import json
|
| 27 |
+
import os
|
| 28 |
+
from pathlib import Path
|
| 29 |
+
|
| 30 |
+
try: # FastAPI types are present in the a11oy image
|
| 31 |
+
from fastapi import FastAPI, Request
|
| 32 |
+
from fastapi.responses import JSONResponse, FileResponse
|
| 33 |
+
except Exception: # pragma: no cover — import-time guard
|
| 34 |
+
FastAPI = object # type: ignore
|
| 35 |
+
Request = object # type: ignore
|
| 36 |
+
|
| 37 |
+
import szl_connectors as sc
|
| 38 |
+
from szl_connectors import oauth as _oauth
|
| 39 |
+
|
| 40 |
+
_PAGES_DIR = Path(os.environ.get("SZL_PAGES_DIR", "/app/pages"))
|
| 41 |
+
_INDEX_HTML = Path("/app/static/index.html")
|
| 42 |
+
|
| 43 |
+
|
| 44 |
+
def register(app, ns: str = "a11oy") -> str:
|
| 45 |
+
"""Mount every connector endpoint. Returns a status string (logged by serve.py)."""
|
| 46 |
+
base = f"/api/{ns}/v1"
|
| 47 |
+
|
| 48 |
+
# ── manifest (honest scoreboard; cheap path by default) ────────────────
|
| 49 |
+
@app.get(f"/api/{ns}/connectors", include_in_schema=False)
|
| 50 |
+
async def _manifest(probe: int = 0, category: str = ""): # noqa: ANN202
|
| 51 |
+
return JSONResponse(sc.manifest(probe=bool(probe), category=category or None))
|
| 52 |
+
|
| 53 |
+
@app.get(f"{base}/connectors", include_in_schema=False)
|
| 54 |
+
async def _manifest_v1(probe: int = 0, category: str = ""): # noqa: ANN202
|
| 55 |
+
return JSONResponse(sc.manifest(probe=bool(probe), category=category or None))
|
| 56 |
+
|
| 57 |
+
# ── per-connector health ───────────────────────────────────────────────
|
| 58 |
+
@app.get(f"{base}/connectors/{{cid}}/health", include_in_schema=False)
|
| 59 |
+
async def _health(cid: str): # noqa: ANN202
|
| 60 |
+
return JSONResponse(sc.health(cid))
|
| 61 |
+
|
| 62 |
+
# ── per-connector read (live | READY | SAMPLE — NEVER fabricated) ────────
|
| 63 |
+
@app.get(f"{base}/connectors/{{cid}}/read", include_in_schema=False)
|
| 64 |
+
async def _read(cid: str, limit: int = 12, q: str = ""): # noqa: ANN202
|
| 65 |
+
c = sc.get(cid)
|
| 66 |
+
if not c:
|
| 67 |
+
return JSONResponse({"error": f"unknown connector '{cid}'",
|
| 68 |
+
"known": sc.all_ids()}, status_code=404)
|
| 69 |
+
query = {"limit": max(1, min(int(limit), 50))}
|
| 70 |
+
if q:
|
| 71 |
+
query["q"] = q
|
| 72 |
+
try:
|
| 73 |
+
return JSONResponse(c.read(query).to_dict())
|
| 74 |
+
except Exception as e: # honest error, never crash
|
| 75 |
+
return JSONResponse({"connector_id": cid, "state": "error",
|
| 76 |
+
"records": [], "live": False,
|
| 77 |
+
"note": f"read failed: {type(e).__name__}: {e}"},
|
| 78 |
+
status_code=502)
|
| 79 |
+
|
| 80 |
+
# ── per-connector write (Λ-gated + DSSE-receipted) ───────────────────────
|
| 81 |
+
@app.post(f"{base}/connectors/{{cid}}/write", include_in_schema=False)
|
| 82 |
+
async def _write(cid: str, request: Request): # noqa: ANN202
|
| 83 |
+
c = sc.get(cid)
|
| 84 |
+
if not c:
|
| 85 |
+
return JSONResponse({"error": f"unknown connector '{cid}'",
|
| 86 |
+
"known": sc.all_ids()}, status_code=404)
|
| 87 |
+
if not getattr(c, "writable", False):
|
| 88 |
+
return JSONResponse({"connector_id": cid, "ok": False,
|
| 89 |
+
"detail": "connector is read-only"}, status_code=405)
|
| 90 |
+
try:
|
| 91 |
+
action = await request.json()
|
| 92 |
+
except Exception:
|
| 93 |
+
action = {}
|
| 94 |
+
try:
|
| 95 |
+
return JSONResponse(c.write(action or {}).to_dict())
|
| 96 |
+
except Exception as e:
|
| 97 |
+
return JSONResponse({"connector_id": cid, "ok": False, "state": "error",
|
| 98 |
+
"detail": f"write failed: {type(e).__name__}: {e}"},
|
| 99 |
+
status_code=502)
|
| 100 |
+
|
| 101 |
+
# ── OAuth2 start: build PKCE authorize URL with signed state ─────────────
|
| 102 |
+
@app.get(f"{base}/connectors/{{cid}}/oauth/start", include_in_schema=False)
|
| 103 |
+
async def _oauth_start(cid: str, request: Request, redirect_uri: str = ""): # noqa: ANN202
|
| 104 |
+
ru = redirect_uri or str(request.url.replace(path=f"{base}/connectors/{cid}/oauth/callback", query=""))
|
| 105 |
+
try:
|
| 106 |
+
out = _oauth.build_authorize_url(cid, redirect_uri=ru)
|
| 107 |
+
return JSONResponse(out)
|
| 108 |
+
except Exception as e:
|
| 109 |
+
return JSONResponse({"connector_id": cid, "error": str(e),
|
| 110 |
+
"detail": "no OAuth profile for this connector or misconfigured"},
|
| 111 |
+
status_code=400)
|
| 112 |
+
|
| 113 |
+
# ── OAuth2 callback: exchange code→token; emit credential-bound receipt ──
|
| 114 |
+
@app.get(f"{base}/connectors/{{cid}}/oauth/callback", include_in_schema=False)
|
| 115 |
+
async def _oauth_callback(cid: str, request: Request, code: str = "", state: str = ""): # noqa: ANN202
|
| 116 |
+
ru = str(request.url.replace(query=""))
|
| 117 |
+
try:
|
| 118 |
+
out = _oauth.exchange_code(cid, code=code, state=state, redirect_uri=ru)
|
| 119 |
+
# NEVER return the token value to the browser; only the receipt + status
|
| 120 |
+
safe = {k: v for k, v in out.items() if k not in ("access_token", "refresh_token")}
|
| 121 |
+
return JSONResponse(safe)
|
| 122 |
+
except Exception as e:
|
| 123 |
+
return JSONResponse({"connector_id": cid, "error": str(e)}, status_code=400)
|
| 124 |
+
|
| 125 |
+
# ── standalone Integrations / Enterprise Mesh page ───────────────────────
|
| 126 |
+
@app.get("/integrations", include_in_schema=False)
|
| 127 |
+
async def _integrations_page(): # noqa: ANN202
|
| 128 |
+
f = _PAGES_DIR / "integrations.html"
|
| 129 |
+
if f.is_file():
|
| 130 |
+
return FileResponse(f, media_type="text/html")
|
| 131 |
+
if _INDEX_HTML.is_file():
|
| 132 |
+
return FileResponse(_INDEX_HTML, media_type="text/html")
|
| 133 |
+
return JSONResponse({"error": "integrations page not found"}, status_code=404)
|
| 134 |
+
|
| 135 |
+
counts = sc.manifest(probe=False)["scoreboard"]
|
| 136 |
+
return (f"szl_connectors mounted: /api/{ns}/connectors (manifest) + "
|
| 137 |
+
f"{base}/connectors/{{cid}}/(health|read|write|oauth/start|oauth/callback) "
|
| 138 |
+
f"+ /integrations page · {len(sc.REGISTRY)} connectors "
|
| 139 |
+
f"(connected={counts.get('connected')} ready={counts.get('ready')} "
|
| 140 |
+
f"sample={counts.get('sample')})")
|
| 141 |
+
|
| 142 |
+
|
| 143 |
+
__all__ = ["register"]
|
| 144 |
+
|
| 145 |
+
# Doctrine v11 LOCKED — 749/14/163 — Λ = Conjecture 1 · honest CONNECTED/READY/SAMPLE ·
|
| 146 |
+
# no fabricated records · writes Λ-gated + DSSE-receipted · 0 runtime CDN.
|