Skip to content
Open
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
10 changes: 9 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,14 @@ Direct runtime profiles can usually run from market history or portfolio state.
4. Confirm rollback steps and artifact versions.
5. Enable scheduled or live execution only after the above checks are clear.

## Daily session renewal

The existing Scheduler-backed renewal endpoint now claims a dedicated GCS cycle
before login and blocks replay after failures or unknown outcomes. MFA and device
verification stop for owner recovery. A broker-free deadline endpoint checks
completion by 21:15 UTC. See [renewal and alert setup](docs/session-renewal.md) for
notification delivery semantics and the separately approved watchdog rollout.

## Read-only account facts

The existing **Diagnose cached-balance stage failure** manual workflow can inspect saved
Expand All @@ -81,7 +89,7 @@ The existing Runtime Target Lifecycle manual entry with `metadata_only=true` ins

`POST /account-facts-sync` is a cached-session-only balance snapshot path. It is disabled unless `FIRSTRADE_ACCOUNT_FACTS_SYNC_ENABLED=true`; it does not log in, refresh credentials, schedule itself, submit orders, or send notifications. It publishes only provider-reported equity and, when present, provider `cash_balance`; it does not read positions, so a positions read failure or incomplete positions cannot block the balance snapshot. Buying power and positions are not used to calculate assets or cash.

The existing daily Execution Report Heartbeat also invokes this snapshot path once through the already-configured Scheduler bridge. Its independent snapshot job checks the exact service URL, POST route and OIDC audience, never creates or updates the bridge, and requires a new successful Scheduler completion plus the matching Cloud Run request HTTP 200. The endpoint returns 200 only after the publisher accepts its acknowledgement; its INFO log may be suppressed and is not required. Dispatch acceptance alone is not success, and independent website latest/identity/history readback remains a separate acceptance check. The existing daily schedule is unchanged; report and notification jobs remain independent. A manual `snapshot_only=true` run checks this path without reports or notifications. Expired/missing cached authentication fails the snapshot job; `/session-renew` remains manual and is never invoked automatically. No Cloud Run image update is needed for this workflow-only integration.
The existing daily Execution Report Heartbeat also invokes this snapshot path once through the already-configured Scheduler bridge. Its independent snapshot job checks the exact service URL, POST route and OIDC audience, never creates or updates the bridge, and requires a new successful Scheduler completion plus the matching Cloud Run request HTTP 200. The endpoint returns 200 only after the publisher accepts its acknowledgement; its INFO log may be suppressed and is not required. Dispatch acceptance alone is not success, and independent website latest/identity/history readback remains a separate acceptance check. The existing daily schedule is unchanged; report and notification jobs remain independent. A manual `snapshot_only=true` run checks this path without reports or notifications. Expired/missing cached authentication fails the snapshot job; `/session-renew` is never invoked by this snapshot workflow. No Cloud Run image update is needed for this workflow-only integration.

The Cloud Run service must remain IAM-protected. The caller needs `roles/run.invoker` and must send its Google-signed ID token in `X-Serverless-Authorization`; the separate application token goes in `Authorization: Bearer …`. See [Cloud Run service-to-service authentication](https://cloud.google.com/run/docs/authenticating/service-to-service). Neither token is a Firstrade credential.

Expand Down
12 changes: 6 additions & 6 deletions application/firstrade_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -75,7 +75,7 @@ class FirstradeCredentials:
@classmethod
def from_env(
cls, env: Callable[[str, str | None], str | None] = os.getenv,
*, include_login_credentials: bool = True,
*, include_login_credentials: bool = True, include_mfa_credentials: bool = True,
) -> "FirstradeCredentials":
def _get_credential(secret_name: str, env_var: str) -> str:
try:
Expand All @@ -90,11 +90,11 @@ def _get_credential(secret_name: str, env_var: str) -> str:
return cls(
username=username.strip(),
password=password,
pin=(env("FIRSTRADE_PIN", "") or "") if include_login_credentials else "",
email=(env("FIRSTRADE_MFA_EMAIL", "") or "") if include_login_credentials else "",
phone=(env("FIRSTRADE_MFA_PHONE", "") or "") if include_login_credentials else "",
mfa_secret=_get_credential("firstrade-mfa-secret", "FIRSTRADE_MFA_SECRET") if include_login_credentials else "",
mfa_code=(env("FIRSTRADE_MFA_CODE", "") or "") if include_login_credentials else "",
pin=(env("FIRSTRADE_PIN", "") or "") if include_login_credentials and include_mfa_credentials else "",
email=(env("FIRSTRADE_MFA_EMAIL", "") or "") if include_login_credentials and include_mfa_credentials else "",
phone=(env("FIRSTRADE_MFA_PHONE", "") or "") if include_login_credentials and include_mfa_credentials else "",
mfa_secret=_get_credential("firstrade-mfa-secret", "FIRSTRADE_MFA_SECRET") if include_login_credentials and include_mfa_credentials else "",
mfa_code=(env("FIRSTRADE_MFA_CODE", "") or "") if include_login_credentials and include_mfa_credentials else "",
cookie_dir=env("FIRSTRADE_COOKIE_DIR", ".runtime/firstrade-cookies")
or ".runtime/firstrade-cookies",
reuse_session=(env("FIRSTRADE_REUSE_SESSION", "false") or "").strip().lower() == "true",
Expand Down
56 changes: 49 additions & 7 deletions application/session_check_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
import json
import os
from collections.abc import Callable, Mapping
from dataclasses import dataclass
from dataclasses import dataclass, replace
from datetime import datetime, timezone
from typing import Any

Expand All @@ -18,7 +18,9 @@
from application.firstrade_client import (
FirstradeBrokerClient,
FirstradeCredentials,
FirstradeCredentialsError,
FirstradeSafetyError,
FirstradeMfaRequired,
is_live_trading_enabled,
mask_account_id,
)
Expand Down Expand Up @@ -501,29 +503,69 @@ def run_session_check(
return result


class SessionRenewalFailure(FirstradeSafetyError):
"""Fixed, non-sensitive failure classification for unattended renewal."""

def __init__(self, reason: str, stage: str):
super().__init__(reason)
self.reason = reason
self.stage = stage


def run_forced_session_renewal(
*,
credentials: FirstradeCredentials | None = None,
client_factory: Callable[..., FirstradeBrokerClient] = FirstradeBrokerClient,
env_reader: Callable[[str, str | None], str | None] = os.getenv,
) -> dict[str, Any]:
"""Manual session renewal: fresh login, select account, read balances, then persist cache.
"""One renewal attempt: fresh login, validate account, then persist shared cache.

Bypasses (never reads, writes, or deletes) the monthly session-check marker. The client is
always built with live trading disabled and only account-read methods are called.
A successful result requires the shared cache write to complete successfully.
"""

resolved_credentials = credentials or FirstradeCredentials.from_env(env_reader)
resolved_credentials = credentials or FirstradeCredentials.from_env(
env_reader, include_mfa_credentials=False,
)
Comment thread
Pigbibi marked this conversation as resolved.
Resolved
try:
resolved_credentials.require_login_fields()
except FirstradeCredentialsError:
raise SessionRenewalFailure("credentials_unavailable", "credentials") from None
# Never consume a seed, code, PIN or OTP destination in unattended renewal.
resolved_credentials = replace(
resolved_credentials, pin="", email="", phone="", mfa_secret="", mfa_code="", debug=False,
)
if not resolved_credentials.reuse_session or not resolved_credentials.persist_session_cache:
raise FirstradeSafetyError("Forced session renewal requires session reuse and cache persistence.")
client = client_factory(resolved_credentials, live_trading_enabled=False)
if getattr(client, "live_trading_enabled", False):
raise FirstradeSafetyError("Forced session renewal requires a non-trading client.")
client.connect(force_login=True, defer_cache_save=True)
account = client.select_account(env_reader("FIRSTRADE_ACCOUNT", "") or None)
balances = client.get_balances(account)
client.save_verified_session_cache()
from firstrade.exceptions import LoginError, LoginRequestError, LoginResponseError

try:
client.connect(force_login=True, defer_cache_save=True)
except FirstradeMfaRequired:
raise
except LoginRequestError as exc:
reason = "auth_rejected" if exc.status_code in {401, 403} else "auth_outcome_unknown"
raise SessionRenewalFailure(reason, "auth") from None
except LoginResponseError:
raise SessionRenewalFailure("auth_rejected", "auth") from None
except LoginError:
# SDK 0.0.39 raises LoginError for device/OTP challenges without a method.
raise FirstradeMfaRequired("Manual authentication required.") from None
except Exception:
raise SessionRenewalFailure("auth_outcome_unknown", "auth") from None
try:
account = client.select_account(env_reader("FIRSTRADE_ACCOUNT", "") or None)
balances = client.get_balances(account)
except Exception:
raise SessionRenewalFailure("account_read_failed", "account_read") from None
try:
client.save_verified_session_cache()
except Exception:
raise SessionRenewalFailure("cache_write_outcome_unknown", "cache_write") from None
print(
"Firstrade forced session renewal "
f"session_reused={bool(getattr(client, 'session_reused', False))} "
Expand Down
Loading
Loading