From 5b3fa26366320881d026c4ff824685a5677f9368 Mon Sep 17 00:00:00 2001 From: underthestars-zhy Date: Mon, 15 Jun 2026 13:03:59 -0700 Subject: [PATCH] fix(photon): unify project identifiers and update documentation for Spectrum provisioning Co-Authored-By: Marvin Co-Authored-By: Claude Opus 4.8 (1M context) --- plugins/platforms/photon/README.md | 18 ++-- plugins/platforms/photon/auth.py | 99 ++++++++----------- plugins/platforms/photon/cli.py | 18 ++-- tests/plugins/platforms/photon/test_auth.py | 55 ++--------- .../platforms/photon/test_setup_access.py | 8 +- 5 files changed, 71 insertions(+), 127 deletions(-) diff --git a/plugins/platforms/photon/README.md b/plugins/platforms/photon/README.md index 1d5d89b57..1989e271f 100644 --- a/plugins/platforms/photon/README.md +++ b/plugins/platforms/photon/README.md @@ -54,8 +54,10 @@ hermes gateway start 1. **Device login** (RFC 8628, `client_id=photon-cli`) — opens `https://app.photon.codes/` for approval and stores the bearer token. 2. **Find or create** the `Hermes Agent` project on the Photon dashboard. -3. **Enable Spectrum**, read the project's `spectrumProjectId`, rotate the - project secret, and persist both. +3. **Provision the project secret** — mint a fresh project secret (the + dashboard reveals it only once) and persist it to `~/.hermes/.env` so the + sidecar can authenticate `spectrum-ts`. Spectrum is always on, so there's no + separate enable step. 4. **Register your phone number** as a Spectrum user (idempotent — skipped if a user with that number already exists). 5. **Print the assigned iMessage line** — the number you text to reach your @@ -75,7 +77,7 @@ Runtime SDK credentials live in `~/.hermes/.env` (the same place every other channel keeps its token), and the adapter reads them from the environment: ```bash -PHOTON_PROJECT_ID= # the SDK's projectId +PHOTON_PROJECT_ID= # the SDK's projectId (same as the dashboard project id) PHOTON_PROJECT_SECRET= ``` @@ -89,8 +91,8 @@ Management metadata lives in `~/.hermes/auth.json` under `credential_pool`: ], "photon_project": [ { - "dashboard_project_id": "", - "spectrum_project_id": "", + "dashboard_project_id": "", + "spectrum_project_id": "", "project_secret": "", "name": "Hermes Agent" } @@ -99,9 +101,9 @@ Management metadata lives in `~/.hermes/auth.json` under `credential_pool`: } ``` -> **Note on ids.** A Photon project has two identifiers: the dashboard `id` -> (used for management API calls) and the `spectrumProjectId` (what the SDK -> authenticates with). `PHOTON_PROJECT_ID` is the **spectrum** id. +> **Note on ids.** A Photon project's dashboard id and its Spectrum project id +> are the same value, exposed as `PHOTON_PROJECT_ID`. The `dashboard_project_id` +> and `spectrum_project_id` keys in `auth.json` both hold that id. ## Configuration knobs diff --git a/plugins/platforms/photon/auth.py b/plugins/platforms/photon/auth.py index 502a6349e..e4e242153 100644 --- a/plugins/platforms/photon/auth.py +++ b/plugins/platforms/photon/auth.py @@ -3,29 +3,29 @@ Photon Dashboard API client + device-code login flow. This module is pure Python — it intentionally does not depend on ``spectrum-ts``. Every management-plane operation (login, find/create -project, enable Spectrum, rotate the project secret, register a user, -list the assigned iMessage line) talks to Photon's **Dashboard API** on a -single host, exactly like the official Photon CLI (``photon-hq/cli``): +project, rotate the project secret, register a user, list the assigned +iMessage line) talks to Photon's **Dashboard API** on a single host, +exactly like the official Photon CLI (``photon-hq/cli``): Dashboard API https://app.photon.codes/api/... OAuth 2.0 device flow, Bearer access token -A Photon project carries two distinct identifiers: - - * ``id`` — the Dashboard project id (used in API paths) - * ``spectrumProjectId`` — the Spectrum Cloud project id, populated when - Spectrum is enabled on the project +A Photon project has a single identifier: the dashboard ``id`` *is* the +Spectrum Cloud project id. They used to diverge (a separate +``spectrumProjectId`` field), but the dashboard unified them — every +project is created with matching ids and the pre-existing diverged rows +were backfilled so ``project.id == spectrumProjectId`` everywhere +(dashboard ENG-1582). Spectrum is always enabled and provisioned at +create-time, so there is no enable/toggle step anymore. The ``spectrum-ts`` SDK (run by the Node sidecar) authenticates to Spectrum -Cloud with ``(spectrumProjectId, projectSecret)`` — so the value we persist -as ``PHOTON_PROJECT_ID`` for the runtime is the **spectrumProjectId**, not -the Dashboard ``id``. The Dashboard ``id`` is kept only for management -calls. +Cloud with ``(id, projectSecret)`` — the same ``id`` used in Dashboard API +paths — which we persist as ``PHOTON_PROJECT_ID`` for the runtime. Credential storage mirrors every other Hermes channel: * runtime SDK creds -> ``~/.hermes/.env`` (``PHOTON_PROJECT_ID`` = - spectrumProjectId, ``PHOTON_PROJECT_SECRET``) via ``save_env_value`` + project id, ``PHOTON_PROJECT_SECRET``) via ``save_env_value`` * management metadata -> ``~/.hermes/auth.json`` under ``credential_pool.photon`` (device token), ``credential_pool.photon_project`` (dashboard id, spectrum id, name), and @@ -148,8 +148,8 @@ def load_project_credentials() -> Tuple[Optional[str], Optional[str]]: Precedence: process env (``~/.hermes/.env`` is loaded into the gateway's environment at startup) wins, then ``auth.json`` for offline / status - use. This is the pair the Node sidecar feeds to ``spectrum-ts`` — the id - is the **spectrumProjectId**, not the Dashboard id. + use. This is the pair the Node sidecar feeds to ``spectrum-ts``; the id + is the unified project id (dashboard id == spectrumProjectId). """ env_id = os.getenv("PHOTON_PROJECT_ID") env_sec = os.getenv("PHOTON_PROJECT_SECRET") @@ -166,14 +166,26 @@ def load_project_credentials() -> Tuple[Optional[str], Optional[str]]: def load_dashboard_project_id() -> Optional[str]: - """Return the Dashboard project id (for management API calls).""" + """Return the project id used for management API calls. + + Post-unification the dashboard id and the Spectrum id are the same value, + so we prefer the stored ``spectrum_project_id``: for pre-backfill installs + the old ``dashboard_project_id`` is the diverged id that the unification + rewrote (it now 404s), while the Spectrum id always matches the live row. + Falls back to the legacy keys for older records. + """ env_id = os.getenv("PHOTON_DASHBOARD_PROJECT_ID") if env_id: return env_id auth = _load_auth() proj = auth.get("credential_pool", {}).get("photon_project") or [] if isinstance(proj, list) and proj: - return proj[0].get("dashboard_project_id") or proj[0].get("project_id") + entry = proj[0] + return ( + entry.get("spectrum_project_id") + or entry.get("dashboard_project_id") + or entry.get("project_id") + ) return None @@ -646,30 +658,23 @@ def find_project_by_name(token: str, name: str) -> Optional[Dict[str, Any]]: return None -def get_project(token: str, project_id: str) -> Dict[str, Any]: - """GET ``/api/projects/{id}`` — includes ``spectrum`` + ``spectrumProjectId``.""" - if httpx is None: - raise RuntimeError("httpx is required for Photon") - url = f"{_dashboard_host()}/api/projects/{project_id}" - resp = httpx.get(url, headers=_bearer(token), timeout=30.0) - resp.raise_for_status() - return resp.json() or {} - - def create_project( token: str, *, name: str = DEFAULT_PROJECT_NAME, location: str = "United States", ) -> Dict[str, Any]: - """POST ``/api/projects`` with ``spectrum: true`` and return ``{success, id}``.""" + """POST ``/api/projects`` and return ``{success, id}``. + + Spectrum is always provisioned at create-time, so the request body no + longer carries a ``spectrum`` flag (the field was dropped from the API). + """ if httpx is None: raise RuntimeError("httpx is required for Photon project creation") url = f"{_dashboard_host()}/api/projects" body: Dict[str, Any] = { "name": name, "location": location, - "spectrum": True, "template": False, "observability": False, } @@ -683,29 +688,6 @@ def create_project( return data -def ensure_spectrum_enabled(token: str, project_id: str) -> Dict[str, Any]: - """Enable Spectrum on the project if needed; return the project dict. - - The dashboard exposes Spectrum as a toggle, so we only flip it when - ``spectrum`` is currently false, then re-fetch to pick up the freshly - populated ``spectrumProjectId``. - """ - if httpx is None: - raise RuntimeError("httpx is required for Photon") - proj = get_project(token, project_id) - if not proj.get("spectrum"): - url = f"{_dashboard_host()}/api/projects/{project_id}/spectrum/toggle" - resp = httpx.post(url, json={}, headers=_bearer(token), timeout=30.0) - resp.raise_for_status() - proj = get_project(token, project_id) - if not proj.get("spectrumProjectId"): - raise RuntimeError( - "Spectrum is enabled but the project has no spectrumProjectId yet — " - "retry in a moment, or enable Spectrum from the dashboard." - ) - return proj - - def regenerate_project_secret(token: str, project_id: str) -> str: """POST ``/api/projects/{id}/regenerate-secret`` → the new project secret. @@ -1007,8 +989,9 @@ def print_credential_summary(emit: Any = print) -> None: else "✗ missing (run `hermes photon setup`)" ) sid, sec = load_project_credentials() - labels["spectrum_project_id"] = sid if sid else "✗ missing" - labels["dashboard_project_id"] = load_dashboard_project_id() or "—" + # Dashboard id and Spectrum id are the same value now (ids unified), so + # there's a single project id to show. + labels["project_id"] = sid if sid else "✗ missing" labels["project_key"] = "✓ stored" if sec else "✗ missing" phone, assigned = load_user_numbers() labels["phone_number"] = ( @@ -1022,8 +1005,7 @@ def print_credential_summary(emit: Any = print) -> None: "Photon iMessage status", "──────────────────────", " device token : " + labels["device_token"], - " dashboard project : " + labels["dashboard_project_id"], - " spectrum project id : " + labels["spectrum_project_id"], + " project id : " + labels["project_id"], " project secret : " + labels["project_key"], " my number : " + labels["phone_number"], " assigned number : " + labels["assigned_phone_number"], @@ -1039,7 +1021,7 @@ def credential_summary() -> Dict[str, str]: else "✗ missing (run `hermes photon setup`)" ) - def _present_spectrum_id() -> str: + def _present_project_id() -> str: sid, _sec = load_project_credentials() return sid or "✗ missing" @@ -1057,8 +1039,7 @@ def credential_summary() -> Dict[str, str]: return { "device_token": _present_token(), - "dashboard_project_id": load_dashboard_project_id() or "—", - "spectrum_project_id": _present_spectrum_id(), + "project_id": _present_project_id(), "project_key": _present_secret(), "phone_number": _present_phone(), "assigned_phone_number": _present_assigned_phone(), diff --git a/plugins/platforms/photon/cli.py b/plugins/platforms/photon/cli.py index 99a6c6ee7..e203d4d14 100644 --- a/plugins/platforms/photon/cli.py +++ b/plugins/platforms/photon/cli.py @@ -164,16 +164,14 @@ def _cmd_setup(args: argparse.Namespace) -> int: print("could not resolve a Photon project id", file=sys.stderr) return 1 - # 3. Enable Spectrum, fetch the spectrum project id, rotate the secret, - # and persist both (runtime creds -> ~/.hermes/.env, ids -> auth.json). + # 3. Rotate the project secret and persist creds (runtime -> ~/.hermes/.env, + # ids -> auth.json). Spectrum is always enabled and provisioned at + # create-time, and the dashboard project id *is* the Spectrum project id + # (ids unified), so there's nothing to enable — the id we already have is + # the Spectrum id. try: - print("[3/5] Enabling Spectrum and provisioning credentials...") - proj = photon_auth.ensure_spectrum_enabled(token, dashboard_id) - spectrum_id = proj.get("spectrumProjectId") - if not spectrum_id: - print("spectrum provisioning failed: no spectrum project id", file=sys.stderr) - return 1 - spectrum_id = str(spectrum_id) + print("[3/5] Provisioning Spectrum credentials...") + spectrum_id = dashboard_id secret = photon_auth.regenerate_project_secret(token, dashboard_id) photon_auth.store_project_credentials( spectrum_project_id=spectrum_id, @@ -182,7 +180,7 @@ def _cmd_setup(args: argparse.Namespace) -> int: name=name, ) # spectrum_id is an opaque non-secret id; safe to show. - print(f" ✓ Spectrum enabled (project id {spectrum_id}) — secret saved") + print(f" ✓ Spectrum ready (project id {spectrum_id}) — secret saved") except Exception as e: print(f"spectrum provisioning failed: {e}", file=sys.stderr) return 1 diff --git a/tests/plugins/platforms/photon/test_auth.py b/tests/plugins/platforms/photon/test_auth.py index 9faf7833d..b3635fddd 100644 --- a/tests/plugins/platforms/photon/test_auth.py +++ b/tests/plugins/platforms/photon/test_auth.py @@ -88,7 +88,10 @@ def test_store_project_credentials_round_trip( sid, secret = photon_auth.load_project_credentials() assert sid == "sp-123" assert secret == "secret-key" - assert photon_auth.load_dashboard_project_id() == "dash-456" + # Post-unification the management id resolves to the Spectrum id, not the + # stored dashboard id — so a pre-backfill diverged install (whose old + # dashboard id was rewritten and now 404s) still reaches the live row. + assert photon_auth.load_dashboard_project_id() == "sp-123" def test_store_project_credentials_writes_env(tmp_hermes_home: Path) -> None: @@ -284,7 +287,7 @@ def test_find_project_by_name_case_insensitive(monkeypatch: pytest.MonkeyPatch) assert proj is not None and proj["id"] == "p2" -def test_create_project_sends_spectrum_true(monkeypatch: pytest.MonkeyPatch) -> None: +def test_create_project_omits_spectrum_flag(monkeypatch: pytest.MonkeyPatch) -> None: captured: Dict[str, Any] = {} def fake_post(url: str, **kwargs: Any) -> _FakeResponse: @@ -296,7 +299,9 @@ def test_create_project_sends_spectrum_true(monkeypatch: pytest.MonkeyPatch) -> monkeypatch.setattr(photon_auth.httpx, "post", fake_post) data = photon_auth.create_project("tok", name="Hermes Agent") assert data["id"] == "new-proj" - assert captured["body"]["spectrum"] is True + # Spectrum is always provisioned at create-time; the field was dropped + # from the API schema, so we must not send it. + assert "spectrum" not in captured["body"] assert captured["body"]["name"] == "Hermes Agent" assert captured["headers"]["Authorization"] == "Bearer tok" assert captured["url"].endswith("/api/projects") @@ -311,46 +316,6 @@ def test_create_project_raises_without_id(monkeypatch: pytest.MonkeyPatch) -> No photon_auth.create_project("tok") -def test_ensure_spectrum_enabled_toggles_when_off(monkeypatch: pytest.MonkeyPatch) -> None: - get_calls = {"n": 0} - posted = {"toggle": False} - - def fake_get(url: str, **kwargs: Any) -> _FakeResponse: - get_calls["n"] += 1 - if get_calls["n"] == 1: - return _FakeResponse(json_body={"id": "p", "spectrum": False, "spectrumProjectId": None}) - return _FakeResponse(json_body={"id": "p", "spectrum": True, "spectrumProjectId": "sp-1"}) - - def fake_post(url: str, **kwargs: Any) -> _FakeResponse: - if url.endswith("/spectrum/toggle"): - posted["toggle"] = True - return _FakeResponse(json_body={"success": True}) - - monkeypatch.setattr(photon_auth.httpx, "get", fake_get) - monkeypatch.setattr(photon_auth.httpx, "post", fake_post) - proj = photon_auth.ensure_spectrum_enabled("tok", "p") - assert posted["toggle"] is True - assert proj["spectrumProjectId"] == "sp-1" - - -def test_ensure_spectrum_enabled_skips_toggle_when_on(monkeypatch: pytest.MonkeyPatch) -> None: - posted = {"toggle": False} - - def fake_get(url: str, **kwargs: Any) -> _FakeResponse: - return _FakeResponse(json_body={"id": "p", "spectrum": True, "spectrumProjectId": "sp-1"}) - - def fake_post(url: str, **kwargs: Any) -> _FakeResponse: - if url.endswith("/spectrum/toggle"): - posted["toggle"] = True - return _FakeResponse(json_body={"success": True}) - - monkeypatch.setattr(photon_auth.httpx, "get", fake_get) - monkeypatch.setattr(photon_auth.httpx, "post", fake_post) - proj = photon_auth.ensure_spectrum_enabled("tok", "p") - assert posted["toggle"] is False - assert proj["spectrumProjectId"] == "sp-1" - - def test_regenerate_project_secret(monkeypatch: pytest.MonkeyPatch) -> None: def fake_post(url: str, **kwargs: Any) -> _FakeResponse: assert url.endswith("/regenerate-secret") @@ -498,8 +463,8 @@ def test_credential_summary_no_secret_leak( assert "secret-bbbb" not in blob assert summary["device_token"].startswith("✓") assert summary["project_key"].startswith("✓") - assert summary["spectrum_project_id"] == "sp-uuid" - assert summary["dashboard_project_id"] == "dash-uuid" + # Unified id: dashboard id == Spectrum id, surfaced as one project id. + assert summary["project_id"] == "sp-uuid" assert summary["phone_number"].startswith("✗ missing") assert summary["assigned_phone_number"].startswith("✗ missing") diff --git a/tests/plugins/platforms/photon/test_setup_access.py b/tests/plugins/platforms/photon/test_setup_access.py index ec4189679..ef27957cd 100644 --- a/tests/plugins/platforms/photon/test_setup_access.py +++ b/tests/plugins/platforms/photon/test_setup_access.py @@ -73,12 +73,10 @@ def test_env_enablement_home_channel_defaults_name(monkeypatch: pytest.MonkeyPat def test_setup_hint_uses_gateway_service_command(monkeypatch: pytest.MonkeyPatch, capsys) -> None: monkeypatch.setattr(cli.photon_auth, "load_photon_token", lambda: "token") + # The dashboard id *is* the Spectrum project id (ids unified), so setup no + # longer enables Spectrum or fetches a separate spectrumProjectId — it + # reuses this id directly. monkeypatch.setattr(cli.photon_auth, "load_dashboard_project_id", lambda: "dashboard") - monkeypatch.setattr( - cli.photon_auth, - "ensure_spectrum_enabled", - lambda token, dashboard_id: {"spectrumProjectId": "project_123"}, - ) monkeypatch.setattr( cli.photon_auth, "regenerate_project_secret",