Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 0 additions & 11 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -7,17 +7,6 @@
NAC_RAPIDAPI_KEY=
NAC_RAPIDAPI_HOST=network-as-code.nokia.rapidapi.com

# --- Bearer for passthrough/ endpoints ---------------------------------------
# There are two ways; fill in one:
# (a) write the ready-made access token you got from the portal directly
NAC_OAUTH_TOKEN=
# (b) or provide the client-credentials details — the probe obtains the token itself.
# Copy the TOKEN_URL value from the application settings in the Nokia portal.
NAC_OAUTH_TOKEN_URL=
NAC_OAUTH_CLIENT_ID=
NAC_OAUTH_CLIENT_SECRET=
NAC_OAUTH_SCOPE=

# --- Probe settings -----------------------------------------------------------
# Check the portal documentation for which test numbers are defined on the SIMULATOR plan;
# write one of them here. If empty, +99999000001000 is tried.
Expand Down
7 changes: 4 additions & 3 deletions DEVELOPER-GUIDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -63,9 +63,10 @@ nac.reachability("+99999000001000")
nac.congestion_query("+99999000001000")
nac.geofence_subscribe("+99999000001000", lat=25.38, lng=51.49, radius_m=500,
sink="https://example.com/webhooks/geofence", types=["org.camaraproject.geofencing-subscriptions.v0.area-entered"])
nac.qod_create("+99999000001000", profile="QOS_E", duration_s=600, app_server_ipv4="203.0.113.10")
nac.qod_create("+99999000001000", app_server_ipv4="203.0.113.10", qos_profile="QOS_E", duration_s=600)
nac.location_retrieve("+99999000001000", max_age_s=60) # after escalation only — the one call that returns a coordinate
```

Deliberately unused: SIM/Device Swap, the KYC family, Number Verification. HeatShield measures exposure; it does not
establish identity, and calling those would violate data minimisation.
Deliberately absent: SIM/Device Swap, the KYC family, Number Verification. HeatShield measures exposure; it does not
establish identity, and calling those would violate data minimisation — so the client does not contain them. The
9 Sep 2026 probe that also tried them can be re-run exactly from tag `v0.1-hackathon`.
10 changes: 8 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -50,8 +50,9 @@ All six are called by the prototype through a single entry point (`packages/nac_
| **Location Retrieval** | Where to send the medic — **after escalation only**, once, coarsened to three decimals before it enters the ledger. The only API here that returns a coordinate, so the most tightly gated. |

**Network Slicing** is a separate Nokia product on the roadmap. It is marked `# MOCK:` in `packages/agent/policy.py`
and is **not** presented as live. Deliberately unused: SIM/Device Swap, the KYC family, Number Verification —
and is **not** presented as live. Deliberately absent: SIM/Device Swap, the KYC family, Number Verification —
HeatShield does not establish identity, it measures exposure, and calling those would violate data minimisation.
The operator client contains only the calls the product makes.

## Running it

Expand Down Expand Up @@ -194,6 +195,11 @@ Location Verification radius scan in
[`evidence/partial-scan-20260909-124602.json`](evidence/partial-scan-20260909-124602.json), and the
endpoint-by-endpoint record in [`docs/api-availability.md`](docs/api-availability.md).

The 9 Sep 2026 live probe in `evidence/` was run with `tools/verify_live.py` at tag
[`v0.1-hackathon`](https://github.com/kutsibalci/heatshield/tree/v0.1-hackathon). That version also probed APIs
the product never calls (KYC, SIM swap, number verification); those have since been removed from the codebase, so
their results in the evidence file are historical. The probe can be re-run exactly from that tag.

**On WBGT — and this is the biggest gap in the prototype.** The product does not measure WBGT, it consumes it.
Today it consumes it **from outside**: the value is pushed in by `POST /v1/sites/{site_id}/wbgt` and the ledger
records it as `source: meteorology-feed`. **No weather provider is integrated.** There is no code in this
Expand Down Expand Up @@ -309,7 +315,7 @@ modes need no credentials at all — the demos and the full test suite run with
## Status and what comes next

- **13 Sep 2026** — prototype submitted. **21 Sep 2026** — Demo Day: presented live to the jury as one of
ten finalists. Results pending.
ten finalists.
- **Next, in this order:**
1. Measure what Quality on Demand actually adds on the escalation path — the jury asked, and we have not
measured it yet. Until then it stays what it is today: outside the verification budget and outside the
Expand Down
91 changes: 0 additions & 91 deletions apps/simulator/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -55,102 +55,13 @@ def _phone(body: dict) -> str:
return p


def _require_bearer(request: Request):
# Passthrough APIs require Bearer in live; the simulator only warns (mandatory with SIM_STRICT_AUTH=1).
if os.environ.get("SIM_STRICT_AUTH") == "1" and not request.headers.get("authorization", "").startswith("Bearer "):
raise HTTPException(401, {"code": "UNAUTHENTICATED", "message": "a Bearer token is required"})


PT = "/passthrough/camara/v1"


@app.post(f"{PT}/number-recycling/number-recycling/v0.2/check")
def number_recycling(request: Request, body: dict = Body(...)):
_require_bearer(request)
return fx.number_recycling(_phone(body), body.get("specifiedDate"))


@app.post(f"{PT}/call-forwarding-signal/call-forwarding-signal/v0.3/call-forwardings")
def call_forwardings(request: Request, body: dict = Body(...)):
_require_bearer(request)
return fx.call_forwardings(_phone(body))


@app.post(f"{PT}/call-forwarding-signal/call-forwarding-signal/v0.3/unconditional-call-forwardings")
def unconditional_cf(request: Request, body: dict = Body(...)):
_require_bearer(request)
return fx.unconditional_call_forwarding(_phone(body))


@app.post(f"{PT}/kyc-tenure/kyc-tenure/v0.1/check-tenure")
def kyc_tenure(request: Request, body: dict = Body(...)):
_require_bearer(request)
return fx.kyc_tenure(_phone(body), body.get("tenureDate"))


def _snake_fields(body: dict, exclude=("phoneNumber", "ageThreshold", "includeContentLock", "includeParentalControl")) -> dict:
import re
return {re.sub(r"(?<!^)(?=[A-Z])", "_", k).lower(): v for k, v in body.items() if k not in exclude}


@app.post(f"{PT}/kyc-age-verification/kyc-age-verification/v0.1/verify")
def kyc_age(request: Request, body: dict = Body(...)):
_require_bearer(request)
return fx.kyc_age(_phone(body), int(body.get("ageThreshold", 18)), **_snake_fields(body))


@app.post(f"{PT}/kyc-match/kyc-match/v0.3/match")
def kyc_match(request: Request, body: dict = Body(...)):
_require_bearer(request)
return fx.kyc_match(_phone(body), **_snake_fields(body))


@app.post(f"{PT}/number-verification/number-verification/v2/verify")
def number_verify(request: Request, body: dict = Body(...)):
_require_bearer(request)
return fx.number_verify(_phone(body))


@app.post(f"{PT}/sim-swap/sim-swap/v0/check")
def sim_swap_check(request: Request, body: dict = Body(...)):
_require_bearer(request)
return fx.sim_swap_check(_phone(body), body.get("maxAge"))


@app.post(f"{PT}/sim-swap/sim-swap/v0/retrieve-date")
def sim_swap_date(request: Request, body: dict = Body(...)):
_require_bearer(request)
return fx.sim_swap_date(_phone(body))


@app.post(f"{PT}/device-swap/device-swap/v1/check")
def device_swap_check(request: Request, body: dict = Body(...)):
_require_bearer(request)
return fx.device_swap_check(_phone(body), body.get("maxAge"))


@app.post(f"{PT}/device-swap/device-swap/v1/retrieve-date")
def device_swap_date(request: Request, body: dict = Body(...)):
_require_bearer(request)
return fx.device_swap_date(_phone(body))


@app.post(f"{PT}/consent-info/consent-info/v0.1/retrieve")
def consent(request: Request, body: dict = Body(...)):
_require_bearer(request)
return fx.consent(_phone(body), body.get("scopes") or [], body.get("purpose", ""))


@app.post("/device-status/device-reachability-status/v1/retrieve")
def reachability(body: dict = Body(...)):
return fx.reachability(_phone(body))


@app.post("/device-status/device-roaming-status/v1/retrieve")
def roaming(body: dict = Body(...)):
return fx.roaming(_phone(body))


@app.post("/location-retrieval/v0/retrieve")
def location_retrieve(body: dict = Body(...)):
return fx.location_retrieve(_phone(body), body.get("maxAge"))
Expand Down Expand Up @@ -188,8 +99,6 @@ def delete(sid: str):


_sub_routes("/geofencing-subscriptions/v0.3/subscriptions", "geofencing")
_sub_routes("/device-status/device-reachability-status-subscriptions/v0.8/subscriptions", "reachability")
_sub_routes("/device-status/device-roaming-status-subscriptions/v0.8/subscriptions", "roaming")


# ---- QoD ---------------------------------------------------------------------
Expand Down
5 changes: 3 additions & 2 deletions docs/api-availability.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,11 +10,12 @@ from the follow-up scans in the same folder. Nothing here is asserted from docum
| Location Verification | `location-verification/v1` | **200** · well-formed verdict and `lastLocationTime` · `maxAge` is mandatory (422 without it) · in the sandbox the verdict is `FALSE` for every area, including a 200 km circle around the device's own coordinate — put to Nokia | First rung of the cost ladder: a verdict, never a coordinate; loose `maxAge` = cached, strict `maxAge` = fresh fix and liveness probe |
| Device Reachability Status | `device-reachability-status/v1` | **200** · `reachable: true`, `connectivity: ["SMS"]`, `lastStatusTime` | Silent-device detection inside a breached zone; the status can lag, so a strict-freshness fix overrules it |
| Congestion Insights | `congestion-insights/v0` | **200** · four-interval forecast, latest `High` at confidence 56 | Cluster test: several devices dark together in a congested cell is a network event, not a medical one |
| Quality on Demand | `quality-on-demand/v0` | **200** · session opened with `QOS_E`, 600 s, application server `203.0.113.10`; read back from the session list; deleted · `qosStatus` came back `REQUESTED`, not `AVAILABLE` | Guaranteed bandwidth for the medic-to-physician assessment, after escalation |
| Quality on Demand | `quality-on-demand/v1` | **200** · session opened with `QOS_E`, 600 s, application server `203.0.113.10`; read back from the session list; deleted · `qosStatus` came back `REQUESTED`, not `AVAILABLE` | Guaranteed bandwidth for the medic-to-physician assessment, after escalation |
| Location Retrieval | `location-retrieval/v0` | **200** · coordinate returned (the sandbox device sits in Budapest) · platform-reported uncertainty radius **1000 m** | Once, after escalation, coarsened to three decimals before it enters the ledger |

**Not called, on purpose:** Network Slicing is a separate Nokia product and appears in the code only as `# MOCK`. SIM and
Device Swap, KYC and Number Verification establish identity; HeatShield measures exposure and does not need them.
Device Swap, KYC and Number Verification establish identity; HeatShield measures exposure and does not need them —
the client no longer contains them at all. The 9 Sep 2026 live probe in `evidence/` was run with `tools/verify_live.py` at tag `v0.1-hackathon`. That version also probed APIs the product never calls (KYC, SIM swap, number verification); those have since been removed from the codebase, so their results in the evidence file are historical.

**Not integrated:** a weather provider. The WBGT value is an input to `/v1/sites/{id}/wbgt`; the demos feed it from the
scenario. Wiring a meteorological source is roadmap.
Expand Down
Loading
Loading