Skip to content

linear, fireflies: each path is answered by the layer that routes it at the vendor - #1058

Open
nuriyoo wants to merge 122 commits into
mainfrom
graphql-each-path-is-routed-as-the-vendor-routes-it
Open

nuriyoo wants to merge 122 commits into
mainfrom
graphql-each-path-is-routed-as-the-vendor-routes-it

Conversation

@nuriyoo

@nuriyoo nuriyoo commented Oct 26, 2026 •

Copy link
Copy Markdown
Contributor

This is the rest of block 5 of the Linear issue, which is #372, and of block 3 of the Fireflies one, which is #373: the paths other than /graphql. A request to any other path under /linear or /fireflies is answered by the layer that routes it at the vendor: Linear serves its endpoint at each spelling Express hands Apollo Server, and Fireflies at /graphql alone, an escaped letter in it decoded, each answering the rest as its front and its router do, but for the paths filed as #1057.

What changed

The path as sent (backlot/routers/__init__.py). sent_path is the path a client sends the vendor's own host, read off the raw path with its escapes and with the segment naming the vendor taken off. Each answer below that quotes or reads a path reads this one.

Routing (backlot/main.py). route_a_linear_or_fireflies_path_as_its_front_does routes each /linear and /fireflies path but the endpoint's own: a Linear path Express hands Apollo Server to linear.graphql, any other to the vendor's root route. Starlette's routing is not asked, since its pattern for the endpoint takes /graphql with a newline after it. refuse_a_broken_escape_on_linear_and_fireflies answers a path holding %00, or a % two hex digits do not follow, with Cloudflare's 400 page and closes the connection, ahead of Cloudflare's method answers, and request_target.answer gives it ahead of Cloudflare's answer to a raw byte in the target (request_target.broken_escape).

Linear (backlot/routers/linear.py). express_path reads a path as Express reads it behind Linear's front: a backslash is a slash, an escape of an unreserved character is that character, a run of slashes is one, and ", <, >, ^, `, {, | and } sent bare are escaped in upper case. routes_to_apollo is /graphql in any case, alone or with a slash and anything after it, once its dot segments are resolved (_dots_resolved), and never a path whose .. climbs past the root.

Linear's other paths (backlot/routers/linear.py). The new express route answers every other path: _front first (the load balancer's empty answer, cors, the refusal of an API key under Bearer), then the greeting to a GET or HEAD of /, the methods / takes to its OPTIONS, and finalhandler's not-found page (_not_found) otherwise, its body unread. The refusal of an API key under Bearer is a GraphQL error at a path starting /graphql and Express's envelope at any other.

Fireflies (backlot/routers/fireflies.py). nginx_passes is the location nginx passes on to Fastify: once each escape is decoded and each run of slashes is one, /graphql in any case followed by a slash, by nothing or by a newline that ends it. The new unrouted route answers nginx's 404 page at any other path, and at one nginx passes on, Fastify's: the preflight to OPTIONS, and otherwise its 404 quoting the path and query as sent, behind its refusal of a DELETE, PATCH, POST or PUT body it cannot read (_BODIED).

Telemetry (backlot/telemetry/naming.py). A path under a GraphQL source's mount but the endpoint's own is named unmatched; one the routing hands the endpoint is named as the endpoint's call. The answers that newly reach the telemetry middleware, as test_a_call_is_named_by_the_baseline holds them:

request                        operation          support    status   error code   error_class
Linear POST /GRAPHQL/          query.__typename   extra      200      (none)       (none)
Linear POST /nosuch            unmatched          unknown    404      (none)       client
Linear GET /                   unmatched          unknown    200      (none)       (none)
Linear POST /a%zz              unmatched          unknown    400      (none)       client
Fireflies POST /graphql/       unmatched          unknown    404      not_found    client
Fireflies POST /nosuch         unmatched          unknown    404      (none)       client

Docs. docs/supported-sources.md says at which paths each endpoint is served and what answers the rest.

Tests. test_linear_answers_each_method_path_type_and_accept_as_linear_does and test_fireflies_answers_each_method_path_type_and_accept_as_fireflies_does, renamed from test_linear_answers_each_method_type_and_accept_as_linear_does and test_fireflies_answers_each_method_type_and_accept_as_fireflies_does with every row those held, hold 230 and 227 requests as each vendor answered them, 67 and 52 of them at other paths. test_cloudflare_answers_ahead_of_linear_and_fireflies holds the broken escapes beside the repeated Authorization and Cloudflare's own methods, and test_linears_front_escapes_a_character_httpx_escapes_first the six characters httpx escapes before it sends a path.

Not in this PR. The headers and the trailing newline of #372 block 6 are #1061, and those of #373 block 5 are #1062. A dot segment, which Linear resolves and Cloudflare refuses past the root, <, >, ` and " in a Fireflies target, which its Node server refuses with an empty 400, and an escape that is not UTF-8 after Fireflies' /graphql/, which Fastify refuses with FST_ERR_BAD_URL, are filed as #1057. A broken escape on Notion and HubSpot, which Cloudflare refuses there with the same page, is filed as #1086.

Why this is what the real API does

Measured. api.linear.app and api.fireflies.ai on 2026-10-25 (UTC), with the key, no credential and nope, and on Linear an API key under Bearer, one request per row with its own cache-buster, by raw HTTP/1.1 so each path went as typed:

sent        Linear   Fireflies
requests    577      540
paths       200      174
methods     11       10
between a and b, on each vendor    each printable ASCII character but # and ?, three bytes past ASCII,
                                   five escapes

The layers answered in this order:

layer          Linear                                            Fireflies
Cloudflare     a broken escape or %00 in the path: its 400 page, the connection closed (2026-10-26); then
               its TRACE, CONNECT and PURGE answers
front          the load balancer's 501, cors, the 400 for an     nginx: its 404 page off the /graphql location
               API key under Bearer, whose envelope the path
               as sent picks
router         Express: /graphql in any case, alone or with a    Fastify: /graphql alone, an escaped letter in it
               slash and more after it, to Apollo Server; /      decoded; any other path its 404, quoting the path
               the greeting; any other path finalhandler's       and query as sent, after reading a DELETE, PATCH,
               404 page quoting the path as Express read it      POST or PUT body

The docstrings of express_path, routes_to_apollo, express, nginx_passes, Fireflies' _not_found and _fastify_read, request_target.broken_escape and the two backlot/main.py middlewares name the paths behind each rule. On 2026-10-26:

request                                                     answer
Linear PATCH /                                              its 404 page
Fireflies PROPFIND /graphql/ under a/b/c, and with {        its 404, where a POST under a/b/c drew its 415
  as JSON
Linear /./graphql, /x/../graphql, /graphql/.,               served
  /graphql/x/.., /graphql/x/%2E%2E
Linear /graphql/.., /graphql/../x, /graphql/%2e%2E/x,       Express's 404
  /graphql/x/../..
Linear /../graphql                                          Cloudflare's 400 page
both, %zz or %00 in the path beside a raw byte              Cloudflare's 400 page
both, %zz or %00 in the query beside a raw byte             the empty 400

Replayed over backlot.serve() at 8dc27059, each request as sent, comparing the status, the media type and the body without its trailing newline:

vendor      sent   same   the rest, all in #1057
Linear      577    546    31  a dot segment
Fireflies   540    484    39  a dot segment, 8 a byte Node refuses, 9 an escape that is not UTF-8

Verification

  • Tests — pytest on uv sync --all-extras --locked plus uv pip install --no-deps "llama-index-readers-hubspot<0.6", as CI installs: 8731 passed, 1 skipped.
  • Optional surfaces that ran rather than skipped — every one but the Atlassian MCP test in tests/test_mcp.py, which needs Docker's Linux containers and found no daemon here.
  • Lint — ruff check . && ruff format --check .: clean.

At ea30e3f7, on main at 96c2cebd.

uv sync --all-extras --locked
uv pip install --no-deps "llama-index-readers-hubspot<0.6"
uv run --no-sync pytest -rs                     8731 passed, 1 skipped in 72.36s
ruff check . && ruff format --check .           All checks passed! / 193 files already formatted
python scripts/prose_check.py <the base>        exit 0
mutations of each line this branch adds         169 (16 by hand), 158 caught

Each line this branch adds was mutated against each test file this branch changes, with operators generated from its lines and, by hand, its regular expressions, the dot segments and the broken escape beside a raw byte. None of the mutations left standing can change an answer here: swaps of checks that have no side effects or cannot both hold, a fallback for a scope without raw_path, which uvicorn and httpx's transport both set, OPTIONS at / widened to methods the check before it answers, and a telemetry prefix nothing reads. The broken escape ahead of a raw byte, answered so on every vendor, is how Notion and HubSpot answer too, which is #1086.

  • A test fails without this change — test_linear_answers_each_method_path_type_and_accept_as_linear_does, test_fireflies_answers_each_method_path_type_and_accept_as_fireflies_does, test_cloudflare_answers_ahead_of_linear_and_fireflies, test_a_call_is_named_by_the_baseline.
  • A new endpoint serving corpus content is ACL-scoped, proved for both an admin and a scoped token — a Linear POST to /GRAPHQL/ reaches the resolvers: test_linear_by_id_roots_do_not_leak_entities_off_hidden_issues.
  • Comments state what was measured, not the history of the fix

Both vendors run Apollo Server 4 over graphql-js. The engine now answers
a request the way that pair does: Apollo Server's checks of the body and
of a persisted query, then graphql-js's parse, validation, choice of
operation and coercion of variables, each refusal with Apollo Server's
code and graphql-js's wording (backlot.graphql.js_words). Each router
writes the refusals in its vendor's envelope, and Linear answers them
up to the choice of operation before it reads the credential.

The telemetry classifier reads Fireflies' code beside the message, and
the GraphQL message slugs those codes replace are gone.
…at JSON cannot reach

A table of documents over a schema of every input kind holds graphql-js
16.14.2's answers under Node, picked so that each change to js_words a
row can tell apart has one. The vendor tables gain the rows Linear and
Fireflies answered for the rest: an error's place after a line
separator, a persisted query's version, the unreachable fragments, and
the body checks two at a time.

js_words drops the branches no request reaches: a circular value, a
variable whose type validation already refused, a scalar's GraphQLError.
…looked up

Both vendors answered a hash sent with `query` false or 0 as with no
query at all, PersistedQueryNotFound at 200, and hashed an empty string.
…ot Backlot's failure

Fireflies has no subscription type, and graphql-core refuses a
subscription with the operation as the error's node. That is the
request's fault, so the call is counted `client`, as the 200 Fireflies
answers with says.
…iteral, and a refusal is read off the field error's own nodes
…near's, and a not-found carries Linear's members

Linear answers a document whose root fields are all __schema or __type
with no credential, and its 401 carries seven extensions members. A
by-id root given an id it holds nothing for is "Entity not found: <type>"
with INPUT_ERROR and the presentable text in extensions, and a field
error's members come in Linear's order, path before locations.
…r that answers it at the vendor

Linear runs Express, cors and body-parser in front of Apollo Server, and Fireflies Fastify and
@fastify/cors; Cloudflare stands in front of both. Each layer now answers what it answers there:
Cloudflare's TRACE, CONNECT and PURGE, Linear's load balancer's 501, each vendor's CORS preflight,
body-parser's and Fastify's reading of a body by its content type, Fastify's 404 for a method its
route does not take, and Apollo Server's CSRF check, its 405, a GET's query string, its 415 for a
GET's content type on Fireflies, its landing page on Fireflies and its choice of media type from
Accept, with its 406. Fireflies' auth_failed 500 is counted as a refusal of the credential.
…d a hash alone with once its query was served
…os' into linear-introspection-and-401-are-linears
…-linears' into graphql-the-http-layer-is-apollos-and-expresss
…eader list are read as each vendor reads them

Linear reads Content-Type sent twice joined and Fireflies the first; Linear's front gives a body to
any method but GET and HEAD; each preflight echoes the copies of Access-Control-Request-Headers as
its CORS middleware does. The negotiator port loses branches that cannot change an answer, and the
tables gain the rows that tell each decision apart: every method the vendors answered, the ACL
through a GET and a form, Cloudflare's methods on other paths, and each library's own answers.
…ds a rule, and the counts are the ones measured
…at the vendor

Linear serves its endpoint at each path Express hands Apollo Server once
Linear's front has read it (any case, a slash and more after it, a run of
slashes as one, an escaped unreserved character decoded, a backslash as a
slash); `/` answers Express's greeting and any other path finalhandler's
404 page, behind the load balancer, cors and the API-key-under-Bearer 400,
whose envelope the path as sent picks. Fireflies serves `/graphql` alone;
a path nginx passes on gets Fastify's 404 after its body is read, and any
other nginx's 404 page. Cloudflare's 400 page answers a path holding `%00`
or a broken escape on both, ahead of its method answers.

Measured against api.linear.app and api.fireflies.ai on 2026-10-25 (UTC).
…ad off the endpoint, and docstrings narrowed to what was measured
…or-is-apollos

# Conflicts:
#	backlot/telemetry/errors.py
#	tests/test_telemetry.py
…the-http-layer-is-apollos-and-expresss

# Conflicts:
#	backlot/main.py
…s both vendors' did

Measured on 2026-10-26 over raw HTTP/1.1: api.linear.app and api.fireflies.ai sent
`Connection: close` with the TRACE 405 and answered no second request on the connection, and
kept it open after the CONNECT and PURGE 400s.
…aphql-each-path-is-routed-as-the-vendor-routes-it

# Conflicts:
#	backlot/main.py
…or-is-apollos

# Conflicts:
#	backlot/telemetry/errors.py
#	tests/test_telemetry.py
…mpty, and Fireflies refuses an operation named __proto__

Apollo Server refuses a body for having no query only when it sends no
persistedQuery extension; with one, a string query whose hash matches,
"" included, goes on to be parsed. Both vendors answered "" with its hash
with graphql-js's Syntax Error on 2026-10-28 (UTC).

graphql-depth-limit keeps the operations in a plain object, so an operation
named __proto__ becomes its prototype and its for...in throws. Fireflies
answers any document holding one with its 500.

The engine's MAX_VALIDATION_ERRORS is now the one constant the Fireflies
depth rule reads. Docstrings say a refusal's message is Apollo Server's,
in graphql-js's words where graphql-js made it, and js_words names
graphql-js 16 as its scope.
…the-http-layer-is-apollos-and-expresss

# Conflicts:
#	backlot/graphql/engine.py
#	tests/test_cross_vendor.py
…le, each row with its answer

The rows for TRACE, CONNECT and PURGE join the table of a repeated
Authorization, each with the status, media type, length, Connection and
body Cloudflare sent (measured 2026-10-28 UTC). TRACE with Authorization
twice is the vendor tables' row already; If-None-Match twice ahead of each
method is new, as main now refuses it too.

The route docstrings describe one route, not one POST route. headers_seen
keeps its rule and date and leaves the cases to the vendor tables, and
Refusal's thrown keeps only what Accept cannot do. main.py names the
renamed repeated-header middleware. NOTICE.md carries Apollo Server's MIT
notice for the landing page copied from it.
…aphql-each-path-is-routed-as-the-vendor-routes-it

# Conflicts:
#	backlot/routers/fireflies.py
#	backlot/routers/linear.py
#	tests/test_cross_vendor.py
…flare table names its 400 page by what it is
…herwise

The five refusals the docstring listed were a sample. Run over documents for
each of graphql-js 16.14.2's validation rules, lexer and parser errors and
value coercions, 17.0.2 also words otherwise the refusal of an argument's
value as the field runs, a null literal for a non-null type, an input object
literal of the wrong shape, a custom scalar's literal refusal, and the syntax
errors at `.5` and `..`; it refuses a subscription to a schema without one as
it validates, adds refusals for a subscription root's @skip/@include and for
@defer/@stream, and orders an unused variable's refusal first.
…near's, and an unknown id is Linear's INPUT_ERROR (#1028)
…presss

# Conflicts:
#	backlot/routers/linear.py
Base automatically changed from graphql-the-http-layer-is-apollos-and-expresss to graphql-a-request-error-is-apollos October 28, 2026 08:57
Base automatically changed from graphql-a-request-error-is-apollos to main October 28, 2026 12:29
@nuriyoo
nuriyoo marked this pull request as ready for review October 28, 2026 12:48
@nuriyoo
nuriyoo requested a review from khj809 October 28, 2026 12:48

@khj809 khj809 left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reviewed for behaviour (live replay against api.linear.app and api.fireflies.ai on 2026-10-29, the PR's server under uvicorn) and for prose.

Behaviour: block

  • backlot/main.py:1483 — Cloudflare answers a broken escape two ways, and the middleware sends only one. When a % is cut short by the end of the path with no query after it (/a%2, /nosucha%b, /a%, /a%0, /%), both vendors send Content-Type: text/html; charset=UTF-8 with Cache-Control: private, max-age=0, no-store, no-cache, must-revalidate, post-check=0, pre-check=0, Expires: Thu, 01 Jan 1970 00:00:01 GMT, Referrer-Policy and X-Frame-Options; Linear sends that answer chunked with no Content-Length, Fireflies with Content-Length: 155. When something follows the escape (/a%2/, /a%2?x=1, /a%zz, /a%00, /graphql%z), both send the plain text/html, length 155 page this PR serves. Reproduce with curl -sS -i -X POST --path-as-is --http1.1 https://api.linear.app/a%2 against .../a%2/. request_target.answer's raw-byte branch makes the same choice and needs the same split.
  • tests/test_cross_vendor.py:414 and :416 — the rows %2 at the end (POST /a%2) and % before b (POST /nosucha%b) expect _BAD_REQUEST (text/html, length 155), an answer neither vendor sends for those paths. Give them the measured media type, headers and framing, and add a row with the broken escape at the end of the path followed by a query as the control.

Everything else matched: 31 Linear and 8 Fireflies paths (/GRAPHQL/, /graph%71l, /%2Fgraphql, /graphql%3F, /graphql/%C3, /%2e/graphql, /x/%2e%2e/graphql, /graphql/x/.., /, /%67raphql/, /graphql/ with a/b/c) gave the same status, media type and body; no request drew a 200 the vendor would not send; the tests the body names fail with the base's backlot/ and docs/ restored; test_acl.py covers POST /GRAPHQL/ for the admin, hana and ava.

Note: test_linears_front_routes_a_path_once_its_dot_segments_are_resolved (tests/test_linear.py:6889) asserts what routes_to_apollo returns rather than the measured answer, and sits beside test_linears_front_escapes_a_character_httpx_escapes_first, which also exists only because httpx cannot send these paths. Both could be one test that sends raw targets over a real uvicorn and compares status and body.

Prose: block

  • docs/supported-sources.md:597 — "/ answers a GET or HEAD with a greeting, and any other method there, or any other path, Express's 404 page" leaves out the OPTIONS at / that express answers with GET, HEAD as text/plain (and the row http_row("OPTIONS", "/", ..., 200, "text/plain", b"GET, HEAD", ...) in tests/test_linear.py:6853). Suggested: "/ answers a GET or HEAD with a greeting and an OPTIONS with the methods it takes, and any other method there, or any other path, Express's 404 page."
  • docs/supported-sources.md:594 (Linear) and :95 (Fireflies) — "with a slash and more after it" excludes /graphql/ with nothing after the slash, which nginx_passes' /graphql(/|$) and routes_to_apollo both take, and the tests serve. Suggested: "with a slash and anything after it".
  • Note, docs/supported-sources.md:95 — "Any other path nginx passes on to Fastify ... gets Fastify's 404" omits that unrouted answers an OPTIONS there with _preflight, and can give _fastify_read's 400/413/415 after reading a body. Suggested: "gets Fastify's 404, or its preflight to an OPTIONS".
  • Note, PR body "Not in this PR" — the dot-segment sentence reads as if dot segments are left out, while "What changed" says routes_to_apollo resolves them and test_linears_front_routes_a_path_once_its_dot_segments_are_resolved covers it. Name only what is still open (Cloudflare's 400 for a .. past the root, Express's 404 quoting the resolved path), and add that test to the Tests paragraph and the "fails without this change" checkbox.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

docs The repo's own documentation: README, docs/, CONTRIBUTING fidelity The vendor API defines the right answer; closing this needs a measurement against it telemetry Usage telemetry: how a call is named, counted and exported, and server changes that alter it

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants