Skip to content

feat(external-rest-api): route the GFS watch through its own GET/PUT edge - #1501

Open
zach88 wants to merge 3 commits into
feat/gfs-event-subscriptions-api-httpfrom
feat/gfs-event-subscriptions-era
Open

zach88 wants to merge 3 commits into
feat/gfs-event-subscriptions-api-httpfrom
feat/gfs-event-subscriptions-era

Conversation

@zach88

@zach88 zach88 commented Nov 14, 2026 •

Copy link
Copy Markdown
Contributor

What

createGfsWatchRouter in external-rest-api: GET and PUT /api/v1/me/gfs/watch, relayed to control-api's /external/gfs/watch (#1500).

  • Chain: request id, its own per-IP limiter, requireAuth, then a per-user bucket (240/min). Mounted before the GFS router, so it does not inherit the authenticated-ip or aggregate-ip buckets.
  • buildUrl appends each value of a string[], so every repeated k reaches control-api in order. A k that is not a string or a list of strings is a 400 at the edge.
  • GET and PUT forward x-request-id and X-Forwarded-For = req.ip, and call control-api with redirect: 'manual'.
  • Propagated with body and allowed headers: 400, 401, 403, 409, 426, 429 and 5xx. Any other ControlApiError and every transport failure become 503 watch_reopen with Retry-After: 2. x-watch-error is forwarded.
  • HEAD and other methods answer 405 with Allow: GET, PUT and never reach control-api.
  • A safe() wrapper turns unexpected throws into a 503 (or destroys the stream once headers are out); the router never calls next(err).

Why

The desktop reaches control-api only through ERA. The watch needs a route that streams, keeps the repeated k parameters intact, and does not spend the aggregate GFS budget the regular GFS routes share: one long-lived stream per window plus small PUTs has a different shape from file reads.

How

  • Shared pieces of the GFS router are exported instead of duplicated: attachGfsRequestId, withGfsRequestId, a createGfsEdgeLimiter factory (each call has its own counter, which keeps the watch budgets separate), and relayControlApiError extracted from forwardControlApiError.
  • Only TypeError('fetch failed') (or one with a cause) counts as transport; any other TypeError is a defect: logged, then the same 503.
  • One exception to the 503 rule: a PUT redirect whose body is not JSON fails in the JSON client and answers 502. It is still never followed and never a 500 or a 200.
  • When the client leaves a GET, the control-api stream is aborted, before or after the 200.

Testing

  • routes.gfsWatch runs ERA's real app against control-api's real watch router on an ephemeral port (fakes only behind control-api), comparing each response direct and through the edge:
    • every k in order with the client address and request id, 64 keys open, 65 keys relayed as control-api's 400, a non-list k rejected at the edge;
    • every error code from control-api reaches the client on GET and PUT, including 426;
    • a redirect or an unlisted status becomes 503 watch_reopen and is never followed (the non-JSON PUT redirect is the 502 above);
    • unreachable control-api, 405 for HEAD and other methods without reaching control-api, upstream stream closed when the client leaves;
    • PUT: 202 relay, Bearer passes the browser mutation guard from a foreign origin, PUTs charged to the watch user bucket and not the aggregate one.
  • routes.gfsWatch.defect: unexpected failure before headers answers 503, never 500; only fetch failures count as transport; a failure after headers cuts the stream.
  • At this head: tsc --noEmit clean, npm test 57 files / 582 tests passed.

Risks / follow-ups

Stack

Live GFS subscriptions ship as a stack. The server half and the desktop half must reach dev together: the server PRs retire the route the current desktop uses, and the desktop PR needs the new watch route.

# PR Base Scope
1 #1496 dev gfs-controller: drive name validation, principal_denied reason
2 #1497 dev host-context-controller: drive name validation (merge after 1)
3 #1498 dev control-api: shared building blocks, per-resource change trigger
4 #1499 3 control-api: watch registry, listener, process runtime
5 #1500 4 control-api: GET/PUT /external/gfs/watch
6 #1501 5 external-rest-api: GET/PUT /api/v1/me/gfs/watch
7 #1502 6 control-api + ERA: authorization and session push, retire the per-user entity-change stream
8 #1503 7 desktop-app: watch client, live previews and folders

1 and 2 are independent of the chain and safe to merge on their own (1 before 2, since the controller check in 2 mirrors the one in 1).

3 to 8 are one unit and should not be deployed partially:

  • 3 and 4 add groundwork; the only new runtime behavior is the gfs_resource_changed NOTIFY trigger, which nothing listens to yet.
  • 5 and 6 expose the route, but until 7 lands an authorization or session change does not cut the changed events of a stream that is idle (no op in flight). Do not ship 5/6 without 7.
  • 7 removes the per-user entity-change stream (ERA /api/v1/entity-changes/stream and control-api /external/entity-changes/stream), which the current desktop still uses. Do not ship 7 without 8.

zach88 and others added 3 commits November 12, 2026 12:42
…edge

Add createGfsWatchRouter for GET/PUT /api/v1/me/gfs/watch, mounted before
the GFS router so it does not inherit authenticated-ip or aggregate-ip.

- Chain: request id -> own per-IP limiter -> requireAuth -> per-user bucket
  (240/min), outside aggregate-ip.
- buildUrl appends each value of a string[] so every repeated `k` reaches
  control-api in order; a non-list `k` is a 400 at the edge.
- GET and PUT forward x-request-id and X-Forwarded-For = req.ip, and call
  control-api with redirect: 'manual'.
- Propagate 400/401/403/409/426/429 and 5xx with body and allowed headers;
  any other ControlApiError and every transport failure become
  503 watch_reopen with Retry-After: 2. x-watch-error is forwarded.
- HEAD and other methods answer 405 with Allow: GET, PUT and never reach
  control-api; a safe() wrapper turns unexpected throws into 503 (or
  destroys the stream once headers are out), never next(err).

Tests run ERA's real app against control-api's real watch router on an
ephemeral port, comparing each response direct and through the edge.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ent-subscriptions-era

Brings origin/dev (22dd421) through the synced parent branch.
Conflict: external-rest-api releaseManifest.ts. Took the v0.12.1
release coordinates from dev and kept this branch's
externalRestApiVersion, which tracks external-rest-api/package.json.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant