Simzy commited on
Commit
2b25951
·
verified ·
1 Parent(s): aa2b0a1

Deploy PR #13: Robinhood refresh wait for newer snapshot (11e93ece)

Browse files
Files changed (3) hide show
  1. README.md +17 -2
  2. main.py +125 -2
  3. test_robinhood.py +124 -11
README.md CHANGED
@@ -46,8 +46,11 @@ Copy the Grok Bot webhook URL and sender key into the Space secrets. Do **not**
46
  - `POST /api/chat` `{ sessionId, clientMsgId, botId, botName, text, history? }` → `{ ok, clientMsgId }` and forwards JSON to `GROK_WEBHOOK_URL`
47
  - `GET /api/chat?sessionId=` → `{ messages: [...] }`
48
  - `POST /api/chat/reply` `Authorization: Bearer ${CHAT_BRIDGE_SECRET}` `{ sessionId, clientMsgId, botId, text, status: "partial"|"final" }`
49
- - `GET /api/robinhood` → `{ "snapshot": null }` or `{ "snapshot": { ... } }` (no auth, `Cache-Control: no-store`)
50
- - `POST /api/robinhood` `Authorization: Bearer ${ROBINHOOD_BRIDGE_SECRET}` replaces the stored snapshot. If that secret is unset, `CHAT_BRIDGE_SECRET` is accepted instead.
 
 
 
51
 
52
  CORS allows `https://simzy420.github.io` and localhost. Text max 8000. `sessionId` must be a UUID.
53
 
@@ -79,3 +82,15 @@ Content-Type: application/json
79
  ```
80
 
81
  Push Casey’s default individual brokerage account. Empty nickname → `"label": "Individual"`. Equity positions only in `positions`. `price` / `marketValue` of `0` or `null` means no live quote.
 
 
 
 
 
 
 
 
 
 
 
 
 
46
  - `POST /api/chat` `{ sessionId, clientMsgId, botId, botName, text, history? }` → `{ ok, clientMsgId }` and forwards JSON to `GROK_WEBHOOK_URL`
47
  - `GET /api/chat?sessionId=` → `{ messages: [...] }`
48
  - `POST /api/chat/reply` `Authorization: Bearer ${CHAT_BRIDGE_SECRET}` `{ sessionId, clientMsgId, botId, text, status: "partial"|"final" }`
49
+ - `GET /api/robinhood` → `{ "snapshot": null, "refreshPending": false }` or `{ "snapshot": { ... }, "refreshPending": false }` (no auth, `Cache-Control: no-store`). When a refresh is waiting on a newer snapshot, the body also includes `refreshPending: true` and `refreshRequestedAt`.
50
+ - `POST /api/robinhood` `Authorization: Bearer ${ROBINHOOD_BRIDGE_SECRET}` replaces the stored snapshot and clears any pending refresh. If that secret is unset, `CHAT_BRIDGE_SECRET` is accepted instead.
51
+ - `POST /api/robinhood/refresh` (public, no auth) stores a pending refresh. Rate limit is one request per 15 seconds per IP. Same path without the `/api` prefix.
52
+ - `GET /api/robinhood/pending-refresh` (public) → `{ "refreshPending": bool, "refreshRequestedAt"?: string }` for Chief of Staff to poll without the snapshot body.
53
+ - `DELETE /api/robinhood/refresh` uses the same bearer as the snapshot POST and clears the pending file. A successful snapshot POST already clears it.
54
 
55
  CORS allows `https://simzy420.github.io` and localhost. Text max 8000. `sessionId` must be a UUID.
56
 
 
82
  ```
83
 
84
  Push Casey’s default individual brokerage account. Empty nickname → `"label": "Individual"`. Equity positions only in `positions`. `price` / `marketValue` of `0` or `null` means no live quote.
85
+
86
+ ### Phone refresh (Chief of Staff must fulfill)
87
+
88
+ The phone never calls Robinhood. **Refresh** does `POST /api/robinhood/refresh`, which writes `robinhood-refresh.json` next to the snapshot:
89
+
90
+ ```json
91
+ { "requestedAt": "2026-09-28T15:04:00Z" }
92
+ ```
93
+
94
+ `refreshPending` is true when that file exists and there is no snapshot yet, or `requestedAt` is later than `snapshot.updatedAt`.
95
+
96
+ Chief of Staff (or a webhook) must notice the pending flag — poll `GET /api/robinhood` or `GET /api/robinhood/pending-refresh` — pull Robinhood, and `POST /api/robinhood` with a new snapshot. That POST clears the pending file. This repo does not change CoS routines. Until the push lands, the phone keeps the last snapshot and, after about 45 seconds, shows a waiting error.
main.py CHANGED
@@ -119,6 +119,92 @@ def save_snapshot(snapshot: dict[str, Any]) -> None:
119
  tmp.replace(path)
120
 
121
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
122
  def robinhood_secret() -> str:
123
  """Dedicated secret wins. Otherwise reuse the chat reply secret."""
124
  dedicated = (os.environ.get("ROBINHOOD_BRIDGE_SECRET") or "").strip()
@@ -264,7 +350,7 @@ app.add_middleware(
264
  ],
265
  allow_origin_regex=r"http://(localhost|127\.0\.0\.1)(:\d+)?",
266
  allow_credentials=False,
267
- allow_methods=["GET", "POST", "OPTIONS"],
268
  allow_headers=["Content-Type", "Authorization", "X-Webhook-Key"],
269
  max_age=86400,
270
  )
@@ -425,7 +511,7 @@ NO_STORE = {"Cache-Control": "no-store"}
425
  @app.get("/robinhood")
426
  @app.get("/api/robinhood")
427
  def get_robinhood() -> JSONResponse:
428
- return JSONResponse({"snapshot": load_snapshot()}, headers=NO_STORE)
429
 
430
 
431
  @app.post("/robinhood")
@@ -447,9 +533,46 @@ async def post_robinhood(request: Request, authorization: str | None = Header(de
447
  raise HTTPException(400, "Invalid Robinhood snapshot.") from exc
448
  snapshot = normalize_snapshot(body)
449
  save_snapshot(snapshot)
 
450
  return JSONResponse({"ok": True, "updatedAt": snapshot["updatedAt"]}, headers=NO_STORE)
451
 
452
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
453
  @app.exception_handler(HTTPException)
454
  async def http_error(_request: Request, exc: HTTPException) -> JSONResponse:
455
  detail = exc.detail if isinstance(exc.detail, str) else json.dumps(exc.detail)
 
119
  tmp.replace(path)
120
 
121
 
122
+ def refresh_file() -> Path:
123
+ return STORE.parent / "robinhood-refresh.json"
124
+
125
+
126
+ def utc_stamp(moment: datetime | None = None) -> str:
127
+ current = moment or datetime.now(timezone.utc)
128
+ return current.astimezone(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
129
+
130
+
131
+ def parse_utc(raw: str | None) -> datetime | None:
132
+ text = (raw or "").strip()
133
+ if not text:
134
+ return None
135
+ try:
136
+ parsed = datetime.fromisoformat(text.replace("Z", "+00:00"))
137
+ except ValueError:
138
+ return None
139
+ if parsed.tzinfo is None:
140
+ parsed = parsed.replace(tzinfo=timezone.utc)
141
+ return parsed.astimezone(timezone.utc)
142
+
143
+
144
+ def load_refresh_request() -> str | None:
145
+ path = refresh_file()
146
+ if not path.is_file():
147
+ return None
148
+ try:
149
+ data = json.loads(path.read_text(encoding="utf-8"))
150
+ except (OSError, json.JSONDecodeError):
151
+ return None
152
+ if not isinstance(data, dict):
153
+ return None
154
+ raw = data.get("requestedAt")
155
+ parsed = parse_utc(raw if isinstance(raw, str) else None)
156
+ if parsed is None:
157
+ return None
158
+ return utc_stamp(parsed)
159
+
160
+
161
+ def save_refresh_request(requested_at: str) -> None:
162
+ path = refresh_file()
163
+ path.parent.mkdir(parents=True, exist_ok=True)
164
+ tmp = path.with_suffix(path.suffix + ".tmp")
165
+ tmp.write_text(json.dumps({"requestedAt": requested_at}), encoding="utf-8")
166
+ tmp.replace(path)
167
+
168
+
169
+ def clear_refresh_request() -> None:
170
+ path = refresh_file()
171
+ path.unlink(missing_ok=True)
172
+ path.with_suffix(path.suffix + ".tmp").unlink(missing_ok=True)
173
+
174
+
175
+ def refresh_is_pending(snapshot: dict[str, Any] | None, requested_at: str | None) -> bool:
176
+ """True when a request is waiting on a snapshot that is missing or older than it."""
177
+ requested = parse_utc(requested_at)
178
+ if requested is None:
179
+ return False
180
+ if not snapshot:
181
+ return True
182
+ updated = parse_utc(str(snapshot.get("updatedAt") or ""))
183
+ if updated is None:
184
+ return True
185
+ return requested > updated
186
+
187
+
188
+ def robinhood_payload() -> dict[str, Any]:
189
+ snapshot = load_snapshot()
190
+ requested_at = load_refresh_request()
191
+ pending = refresh_is_pending(snapshot, requested_at)
192
+ body: dict[str, Any] = {"snapshot": snapshot, "refreshPending": pending}
193
+ if pending and requested_at:
194
+ body["refreshRequestedAt"] = requested_at
195
+ return body
196
+
197
+
198
+ # One public refresh per IP inside this window. Phone retries after the ~45s wait.
199
+ REFRESH_INTERVAL_S = 15.0
200
+
201
+
202
+ def client_addr(request: Request) -> str:
203
+ forwarded = (request.headers.get("x-forwarded-for") or "").split(",")[0].strip()
204
+ host = forwarded or (request.client.host if request.client else "")
205
+ return (host or "unknown")[:80]
206
+
207
+
208
  def robinhood_secret() -> str:
209
  """Dedicated secret wins. Otherwise reuse the chat reply secret."""
210
  dedicated = (os.environ.get("ROBINHOOD_BRIDGE_SECRET") or "").strip()
 
350
  ],
351
  allow_origin_regex=r"http://(localhost|127\.0\.0\.1)(:\d+)?",
352
  allow_credentials=False,
353
+ allow_methods=["GET", "POST", "DELETE", "OPTIONS"],
354
  allow_headers=["Content-Type", "Authorization", "X-Webhook-Key"],
355
  max_age=86400,
356
  )
 
511
  @app.get("/robinhood")
512
  @app.get("/api/robinhood")
513
  def get_robinhood() -> JSONResponse:
514
+ return JSONResponse(robinhood_payload(), headers=NO_STORE)
515
 
516
 
517
  @app.post("/robinhood")
 
533
  raise HTTPException(400, "Invalid Robinhood snapshot.") from exc
534
  snapshot = normalize_snapshot(body)
535
  save_snapshot(snapshot)
536
+ clear_refresh_request()
537
  return JSONResponse({"ok": True, "updatedAt": snapshot["updatedAt"]}, headers=NO_STORE)
538
 
539
 
540
+ @app.post("/robinhood/refresh")
541
+ @app.post("/api/robinhood/refresh")
542
+ def post_robinhood_refresh(request: Request) -> JSONResponse:
543
+ """Public. Phone asks Chief of Staff to pull Robinhood; it never calls Robinhood itself."""
544
+ if rate_limited(f"robinhood:refresh:{client_addr(request)}", max_n=1, window_s=REFRESH_INTERVAL_S):
545
+ raise HTTPException(429, "A refresh was just requested. Wait a few seconds.")
546
+ requested_at = utc_stamp()
547
+ save_refresh_request(requested_at)
548
+ return JSONResponse(
549
+ {"ok": True, "requestedAt": requested_at, "refreshPending": True},
550
+ headers=NO_STORE,
551
+ )
552
+
553
+
554
+ @app.delete("/robinhood/refresh")
555
+ @app.delete("/api/robinhood/refresh")
556
+ def delete_robinhood_refresh(authorization: str | None = Header(default=None)) -> JSONResponse:
557
+ secret = robinhood_secret()
558
+ if not secret:
559
+ raise HTTPException(500, "Space is missing ROBINHOOD_BRIDGE_SECRET (or CHAT_BRIDGE_SECRET).")
560
+ if not bearer_ok(authorization, secret):
561
+ raise HTTPException(401, "Unauthorized.")
562
+ clear_refresh_request()
563
+ return JSONResponse({"ok": True, "refreshPending": False}, headers=NO_STORE)
564
+
565
+
566
+ @app.get("/robinhood/pending-refresh")
567
+ @app.get("/api/robinhood/pending-refresh")
568
+ def get_robinhood_pending() -> JSONResponse:
569
+ payload = robinhood_payload()
570
+ body: dict[str, Any] = {"refreshPending": payload["refreshPending"]}
571
+ if "refreshRequestedAt" in payload:
572
+ body["refreshRequestedAt"] = payload["refreshRequestedAt"]
573
+ return JSONResponse(body, headers=NO_STORE)
574
+
575
+
576
  @app.exception_handler(HTTPException)
577
  async def http_error(_request: Request, exc: HTTPException) -> JSONResponse:
578
  detail = exc.detail if isinstance(exc.detail, str) else json.dumps(exc.detail)
test_robinhood.py CHANGED
@@ -1,10 +1,11 @@
 
1
  import os
2
  import unittest
3
  import uuid
4
 
5
  from fastapi.testclient import TestClient
6
 
7
- from main import RATE, app, snapshot_file
8
 
9
 
10
  SAMPLE = {
@@ -37,29 +38,30 @@ class RobinhoodBridgeTest(unittest.TestCase):
37
  os.environ.pop("ROBINHOOD_BRIDGE_SECRET", None)
38
  os.environ["CHAT_BRIDGE_SECRET"] = "chat-secret-value"
39
  RATE.clear()
40
- path = snapshot_file()
41
- if path.exists():
42
- path.unlink()
43
- tmp = path.with_suffix(path.suffix + ".tmp")
44
- if tmp.exists():
45
- tmp.unlink()
46
  self.client = TestClient(app)
47
 
48
  def tearDown(self) -> None:
49
  self.client.close()
50
- path = snapshot_file()
51
- if path.exists():
52
- path.unlink()
53
  for key, value in self._env.items():
54
  if value is None:
55
  os.environ.pop(key, None)
56
  else:
57
  os.environ[key] = value
58
 
 
 
 
 
 
 
 
 
59
  def test_get_empty_snapshot_is_public(self) -> None:
60
  res = self.client.get("/api/robinhood")
61
  self.assertEqual(res.status_code, 200)
62
- self.assertEqual(res.json(), {"snapshot": None})
63
  self.assertEqual(res.headers.get("cache-control"), "no-store")
64
 
65
  def test_post_requires_bearer_and_masks_account(self) -> None:
@@ -147,6 +149,117 @@ class RobinhoodBridgeTest(unittest.TestCase):
147
  self.assertEqual(polled.status_code, 200)
148
  self.assertEqual(polled.json()["messages"][-1]["text"], "ok")
149
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
150
 
151
  if __name__ == "__main__":
152
  unittest.main()
 
1
+ import json
2
  import os
3
  import unittest
4
  import uuid
5
 
6
  from fastapi.testclient import TestClient
7
 
8
+ from main import RATE, app, refresh_file, snapshot_file
9
 
10
 
11
  SAMPLE = {
 
38
  os.environ.pop("ROBINHOOD_BRIDGE_SECRET", None)
39
  os.environ["CHAT_BRIDGE_SECRET"] = "chat-secret-value"
40
  RATE.clear()
41
+ self._wipe_files()
 
 
 
 
 
42
  self.client = TestClient(app)
43
 
44
  def tearDown(self) -> None:
45
  self.client.close()
46
+ self._wipe_files()
 
 
47
  for key, value in self._env.items():
48
  if value is None:
49
  os.environ.pop(key, None)
50
  else:
51
  os.environ[key] = value
52
 
53
+ def _wipe_files(self) -> None:
54
+ for path in (snapshot_file(), refresh_file()):
55
+ if path.exists():
56
+ path.unlink()
57
+ tmp = path.with_suffix(path.suffix + ".tmp")
58
+ if tmp.exists():
59
+ tmp.unlink()
60
+
61
  def test_get_empty_snapshot_is_public(self) -> None:
62
  res = self.client.get("/api/robinhood")
63
  self.assertEqual(res.status_code, 200)
64
+ self.assertEqual(res.json(), {"snapshot": None, "refreshPending": False})
65
  self.assertEqual(res.headers.get("cache-control"), "no-store")
66
 
67
  def test_post_requires_bearer_and_masks_account(self) -> None:
 
149
  self.assertEqual(polled.status_code, 200)
150
  self.assertEqual(polled.json()["messages"][-1]["text"], "ok")
151
 
152
+ def test_refresh_request_is_public_and_rate_limited_per_ip(self) -> None:
153
+ os.environ.pop("ROBINHOOD_BRIDGE_SECRET", None)
154
+ os.environ.pop("CHAT_BRIDGE_SECRET", None)
155
+ first = self.client.post("/api/robinhood/refresh", headers={"X-Forwarded-For": "203.0.113.10"})
156
+ self.assertEqual(first.status_code, 200)
157
+ body = first.json()
158
+ self.assertTrue(body["ok"])
159
+ self.assertTrue(body["refreshPending"])
160
+ self.assertRegex(body["requestedAt"], r"^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z$")
161
+ self.assertEqual(first.headers.get("cache-control"), "no-store")
162
+ stored = json.loads(refresh_file().read_text(encoding="utf-8"))
163
+ self.assertEqual(stored, {"requestedAt": body["requestedAt"]})
164
+
165
+ again = self.client.post("/api/robinhood/refresh", headers={"X-Forwarded-For": "203.0.113.10"})
166
+ self.assertEqual(again.status_code, 429)
167
+
168
+ other = self.client.post("/robinhood/refresh", headers={"X-Forwarded-For": "203.0.113.11"})
169
+ self.assertEqual(other.status_code, 200)
170
+
171
+ def test_refresh_pending_until_snapshot_is_newer(self) -> None:
172
+ headers = {"Authorization": "Bearer chat-secret-value"}
173
+ old = dict(SAMPLE)
174
+ old["updatedAt"] = "2020-01-01T00:00:00Z"
175
+ self.client.post("/api/robinhood", json=old, headers=headers)
176
+ asked = self.client.post("/api/robinhood/refresh", headers={"X-Forwarded-For": "203.0.113.12"})
177
+ requested_at = asked.json()["requestedAt"]
178
+
179
+ got = self.client.get("/api/robinhood")
180
+ body = got.json()
181
+ self.assertEqual(got.headers.get("cache-control"), "no-store")
182
+ self.assertTrue(body["refreshPending"])
183
+ self.assertEqual(body["refreshRequestedAt"], requested_at)
184
+ self.assertEqual(body["snapshot"]["updatedAt"], "2020-01-01T00:00:00Z")
185
+
186
+ pending = self.client.get("/api/robinhood/pending-refresh")
187
+ self.assertEqual(pending.status_code, 200)
188
+ self.assertEqual(
189
+ pending.json(),
190
+ {"refreshPending": True, "refreshRequestedAt": requested_at},
191
+ )
192
+ alias = self.client.get("/robinhood/pending-refresh")
193
+ self.assertEqual(alias.json()["refreshPending"], True)
194
+
195
+ def test_pending_flag_is_false_when_snapshot_is_already_newer(self) -> None:
196
+ headers = {"Authorization": "Bearer chat-secret-value"}
197
+ future = dict(SAMPLE)
198
+ future["updatedAt"] = "2099-01-01T00:00:00Z"
199
+ self.client.post("/api/robinhood", json=future, headers=headers)
200
+ self.client.post("/api/robinhood/refresh", headers={"X-Forwarded-For": "203.0.113.13"})
201
+ body = self.client.get("/api/robinhood").json()
202
+ self.assertFalse(body["refreshPending"])
203
+ self.assertNotIn("refreshRequestedAt", body)
204
+ self.assertTrue(refresh_file().is_file())
205
+ self.assertEqual(self.client.get("/api/robinhood/pending-refresh").json(), {"refreshPending": False})
206
+
207
+ def test_snapshot_post_clears_pending_refresh(self) -> None:
208
+ self.client.post("/api/robinhood/refresh", headers={"X-Forwarded-For": "203.0.113.14"})
209
+ self.assertTrue(refresh_file().is_file())
210
+ denied = self.client.post("/api/robinhood", json=SAMPLE)
211
+ self.assertEqual(denied.status_code, 401)
212
+ self.assertTrue(self.client.get("/api/robinhood").json()["refreshPending"])
213
+
214
+ res = self.client.post(
215
+ "/api/robinhood",
216
+ json=SAMPLE,
217
+ headers={"Authorization": "Bearer chat-secret-value"},
218
+ )
219
+ self.assertEqual(res.status_code, 200)
220
+ self.assertFalse(refresh_file().exists())
221
+ body = self.client.get("/api/robinhood").json()
222
+ self.assertFalse(body["refreshPending"])
223
+ self.assertNotIn("refreshRequestedAt", body)
224
+ self.assertEqual(self.client.get("/robinhood/pending-refresh").json(), {"refreshPending": False})
225
+
226
+ def test_delete_refresh_requires_snapshot_bearer(self) -> None:
227
+ self.client.post("/api/robinhood/refresh", headers={"X-Forwarded-For": "203.0.113.15"})
228
+ denied = self.client.delete("/api/robinhood/refresh")
229
+ self.assertEqual(denied.status_code, 401)
230
+ self.assertTrue(refresh_file().is_file())
231
+
232
+ os.environ["ROBINHOOD_BRIDGE_SECRET"] = "rh-secret-value"
233
+ chat = self.client.delete(
234
+ "/api/robinhood/refresh",
235
+ headers={"Authorization": "Bearer chat-secret-value"},
236
+ )
237
+ self.assertEqual(chat.status_code, 401)
238
+ self.assertTrue(refresh_file().is_file())
239
+
240
+ ok = self.client.delete(
241
+ "/robinhood/refresh",
242
+ headers={"Authorization": "Bearer rh-secret-value"},
243
+ )
244
+ self.assertEqual(ok.status_code, 200)
245
+ self.assertEqual(ok.json(), {"ok": True, "refreshPending": False})
246
+ self.assertFalse(refresh_file().exists())
247
+ self.assertFalse(self.client.get("/api/robinhood").json()["refreshPending"])
248
+
249
+ def test_refresh_cors_allows_phone_origin(self) -> None:
250
+ res = self.client.options(
251
+ "/api/robinhood/refresh",
252
+ headers={
253
+ "Origin": "https://simzy420.github.io",
254
+ "Access-Control-Request-Method": "POST",
255
+ },
256
+ )
257
+ self.assertIn(res.status_code, (200, 204))
258
+ self.assertEqual(res.headers.get("access-control-allow-origin"), "https://simzy420.github.io")
259
+ allow = res.headers.get("access-control-allow-methods", "")
260
+ self.assertIn("POST", allow)
261
+ self.assertIn("DELETE", allow)
262
+
263
 
264
  if __name__ == "__main__":
265
  unittest.main()