Repository navigation
Conversation
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.
…ersion it cannot show
…ection-and-401-are-linears
…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
…s gain the rows that tell the rest apart
…than dating it again
…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
…ection-and-401-are-linears
…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.
…ection-and-401-are-linears
…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.
…and the notice names the nonce placeholder
… Refusal says whose words they are
…ection-and-401-are-linears
…ection-and-401-are-linears
…the-http-layer-is-apollos-and-expresss
…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
…y the layer that answers it at the vendor (#1029)
…os' into graphql-each-path-is-routed-as-the-vendor-routes-it
…routed-as-the-vendor-routes-it
khj809
left a comment
There was a problem hiding this comment.
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 sendContent-Type: text/html; charset=UTF-8withCache-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-PolicyandX-Frame-Options; Linear sends that answer chunked with noContent-Length, Fireflies withContent-Length: 155. When something follows the escape (/a%2/,/a%2?x=1,/a%zz,/a%00,/graphql%z), both send the plaintext/html, length 155 page this PR serves. Reproduce withcurl -sS -i -X POST --path-as-is --http1.1 https://api.linear.app/a%2against.../a%2/.request_target.answer's raw-byte branch makes the same choice and needs the same split.tests/test_cross_vendor.py:414and: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 aGETorHEADwith a greeting, and any other method there, or any other path, Express's 404 page" leaves out theOPTIONSat/thatexpressanswers withGET, HEADastext/plain(and the rowhttp_row("OPTIONS", "/", ..., 200, "text/plain", b"GET, HEAD", ...)in tests/test_linear.py:6853). Suggested: "/answers aGETorHEADwith a greeting and anOPTIONSwith 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, whichnginx_passes'/graphql(/|$)androutes_to_apolloboth 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 thatunroutedanswers anOPTIONSthere with_preflight, and can give_fastify_read's 400/413/415 after reading a body. Suggested: "gets Fastify's 404, or its preflight to anOPTIONS". - 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_apolloresolves them andtest_linears_front_routes_a_path_once_its_dot_segments_are_resolvedcovers 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.
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/linearor/firefliesis answered by the layer that routes it at the vendor: Linear serves its endpoint at each spelling Express hands Apollo Server, and Fireflies at/graphqlalone, 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_pathis 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_doesroutes each/linearand/firefliespath but the endpoint's own: a Linear path Express hands Apollo Server tolinear.graphql, any other to the vendor's root route. Starlette's routing is not asked, since its pattern for the endpoint takes/graphqlwith a newline after it.refuse_a_broken_escape_on_linear_and_firefliesanswers 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, andrequest_target.answergives it ahead of Cloudflare's answer to a raw byte in the target (request_target.broken_escape).Linear (
backlot/routers/linear.py).express_pathreads 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_apollois/graphqlin 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 newexpressroute answers every other path:_frontfirst (the load balancer's empty answer,cors, the refusal of an API key underBearer), then the greeting to aGETorHEADof/, the methods/takes to itsOPTIONS, andfinalhandler's not-found page (_not_found) otherwise, its body unread. The refusal of an API key underBeareris a GraphQL error at a path starting/graphqland Express's envelope at any other.Fireflies (
backlot/routers/fireflies.py).nginx_passesis the location nginx passes on to Fastify: once each escape is decoded and each run of slashes is one,/graphqlin any case followed by a slash, by nothing or by a newline that ends it. The newunroutedroute answers nginx's 404 page at any other path, and at one nginx passes on, Fastify's: the preflight toOPTIONS, and otherwise its 404 quoting the path and query as sent, behind its refusal of aDELETE,PATCH,POSTorPUTbody it cannot read (_BODIED).Telemetry (
backlot/telemetry/naming.py). A path under a GraphQL source's mount but the endpoint's own is namedunmatched; one the routing hands the endpoint is named as the endpoint's call. The answers that newly reach the telemetry middleware, astest_a_call_is_named_by_the_baselineholds them:Docs.
docs/supported-sources.mdsays at which paths each endpoint is served and what answers the rest.Tests.
test_linear_answers_each_method_path_type_and_accept_as_linear_doesandtest_fireflies_answers_each_method_path_type_and_accept_as_fireflies_does, renamed fromtest_linear_answers_each_method_type_and_accept_as_linear_doesandtest_fireflies_answers_each_method_type_and_accept_as_fireflies_doeswith 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_firefliesholds the broken escapes beside the repeatedAuthorizationand Cloudflare's own methods, andtest_linears_front_escapes_a_character_httpx_escapes_firstthe 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 withFST_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 underBearer, one request per row with its own cache-buster, by raw HTTP/1.1 so each path went as typed:The layers answered in this order:
The docstrings of
express_path,routes_to_apollo,express,nginx_passes, Fireflies'_not_foundand_fastify_read,request_target.broken_escapeand the twobacklot/main.pymiddlewares name the paths behind each rule. On 2026-10-26:Replayed over
backlot.serve()at8dc27059, each request as sent, comparing the status, the media type and the body without its trailing newline:Verification
pytestonuv sync --all-extras --lockedplusuv pip install --no-deps "llama-index-readers-hubspot<0.6", as CI installs: 8731 passed, 1 skipped.tests/test_mcp.py, which needs Docker's Linux containers and found no daemon here.ruff check . && ruff format --check .: clean.At
ea30e3f7, onmainat96c2cebd.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,OPTIONSat/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.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./GRAPHQL/reaches the resolvers:test_linear_by_id_roots_do_not_leak_entities_off_hidden_issues.