`server.https` picked the scheme for every generated link, and it also
decides whether cookies are marked Secure. The two are separate
questions, and a config that said `publicPort = 443` with `https =
false` produced `http://host:443` in every feed `<link>`, `<guid>`, the
OpenSearch template and the unfurl metadata. Nothing serves plain HTTP
on 443, so no such link was ever usable, and nothing warned about it.
Take the scheme from the port when the port is 443 or 80 -- they only
mean one thing -- and from `https` otherwise, so `https://host:8443`
still works. A default port is never appended. The `https` flag keeps
governing cookies, and startup now logs a warning when the port says TLS
and the flag does not, since the cookies are then not Secure.
The logic moves into a small pure function so the combinations can be
tested exhaustively rather than through a loaded config.
Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
The list timeline comes back shaped like every other timeline but spelt
differently: instructions carry `__typename` where the schema read
`type`, and module items carry `tweetResult` where it read
`tweet_results`. Neither mismatch is an error -- the instruction falls
to `Other`, matches no branch, and the timeline parses to zero tweets.
A list that loads with no posts looks like an empty list, which is why
this survived a 500 being fixed in front of it.
Read both spellings. They are separate fields rather than serde aliases
because a payload carrying both would be a duplicate field and fail to
deserialize at all; `user_result` beside `user_results` already sets
that precedent. Route the other three parsers through the same accessor
so a future rename cannot empty a conversation or a search instead.
Co-Authored-By: Claude Opus 5 <[email protected]>
X has moved timeline payloads between `timeline_v2`, `timeline` and
`timeline_response` over time, and `TimelineResultData` already accepts
all three. The list timeline took only `timeline_response`, and a key
that does not match parses to zero instructions rather than an error --
so a rename shows up as a list that loads fine and is simply empty.
Take the same three spellings there. Also log response bodies at trace,
off by default, since an empty parse is otherwise indistinguishable from
an empty list.
Co-Authored-By: Claude Opus 5 <[email protected]>
X answers the list query with a complete `data.list` and one `errors`
entry beside it -- a normal GraphQL partial response, where a sub-field
failed and the rest resolved. `check_api_errors` failed on any error at
all, so it discarded a perfectly good list and every list page, members
page and feed returned 500.
Keep the data when the response carries some, and let the parsers decide
whether what they needed arrived; they already answer `NotFound` when a
field is missing. Errors that the session pool has to act on -- rate
limits, locked accounts, dead tokens -- stay fatal regardless, since
swallowing those would strand a session mid-rotation.
Co-Authored-By: Claude Opus 5 <[email protected]>
`ListByRestId` currently answers Error 214 for every list, while
`ListTimeline` beside it returns the tweets fine. Both routes bound the
metadata with `?`, so a cosmetic query taking down a working timeline
500'd the list page, the members page and the feed alike.
Fall back to an untitled list keyed by its id and log the reason. The
tweets are the page; the name is decoration, and a feed that publishes
without a title beats one that does not publish.
This does not fix `ListByRestId` -- the query appears to have gone stale
upstream -- it stops that failure from being fatal.
Co-Authored-By: Claude Opus 5 <[email protected]>
X answers some rejected queries with HTTP 200 and an `errors` array, and
that path never reaches the status logging, so the body is dropped and
all that survives is a bare code. `Error 214: BadRequest: DecodeException`
on every list page named neither the query nor the variable X objected
to; the same failure over HTTP 422 printed `path: ["variable","rest_id"]`
and settled it immediately.
Log the body, truncated, so the 200-with-errors case is as diagnosable
as the status-code case.
Co-Authored-By: Claude Opus 5 <[email protected]>
`list_timeline_vars` reused the v2 media timeline's `rest_id_page_vars`,
so every `ListTimeline` query named the list `rest_id`. X cannot decode
that and rejects the whole query with
Error 214: BadRequest: com.twitter.strato.serialization.DecodeException
which surfaced as a 500 on every list page and list feed. The other
three list queries -- `ListByRestId`, `ListBySlug`, `ListMembers` --
already send `listId`; the timeline was the one that borrowed a struct
shaped for a different endpoint.
Give it its own variables and leave `rest_id_page_vars` to the media
timeline that genuinely wants `rest_id`. Tests pin both spellings so the
two cannot be unified again by mistake.
Co-Authored-By: Claude Opus 5 <[email protected]>
`behindCloudflare` allowed the challenge host but not Web Analytics,
which loads its beacon from static.cloudflareinsights.com and reports to
cloudflareinsights.com. Both were refused, so a zone with Web Analytics
on still logged a CSP violation on every page -- the flag only half did
what it claims.
Found by reading the console on a live instance: the challenge bootstrap
and JS detection now run, the beacon was the one thing left blocked.
Co-Authored-By: Claude Opus 5 <[email protected]>
Cloudflare injects its JS-detection bootstrap inline into origin HTML.
`script-src 'self'` stops it executing, so the challenge never records
that it was solved and Cloudflare serves another one on the next
request -- a loop no visitor can escape, which reads as a Cloudflare
bug rather than a header of ours.
Allowing it needs `'unsafe-inline'`, since an inline script cannot be
allowlisted by URL and Cloudflare cannot know a nonce we generate. That
is a real weakening, so it is opt-in: the default policy is unchanged
byte for byte, and only an instance Cloudflare actually fronts turns it
on. Nothing else in the policy relaxes.
Co-Authored-By: Claude Opus 5 <[email protected]>
`flag` binary searches both tables, so an entry inserted out of order
stops resolving with no error anywhere -- and the sort order lived only
in a comment. Check it, along with the two invariants the rest of the
lookup rests on: names are lowercased, because the needle is, and codes
are `A..=Z`, because `Flag`'s `Display` offsets off `b'A'` and panics
otherwise.
Co-Authored-By: Claude Opus 5 <[email protected]>
The `/photo/N` unfurl encodes the photo in the status id Discord echoes
back, zero-padding the tweet to 19 digits so the prefix stays outside
snowflake range. Nothing exercised that round trip, and the padding is
easy to break without noticing, so pin the encoding, the plain-id case,
the tenth-photo fallback and the rejections. Same for `keep_only_photo`,
which the unfurl leans on to narrow the media.
Co-Authored-By: Claude Opus 5 <[email protected]>
The panel generated `f-<name>` and `e-<name>` checkboxes for ten filters,
but `SearchQuery` named a subset of nine fields and serde dropped the
rest, so "retweets only" and every exclude but replies did nothing. The
one exclude field that existed, `e-retweets`, was a name no checkbox ever
submitted.
Collect the toggles by prefix instead, validating each against
`VALID_FILTERS`, and drive the checkboxes from a single table so the two
sides cannot drift apart again. An exclude now wins over the matching
include, which would otherwise build a query that can never match.
Profile search and the search feed grew the same handling: both parsed
the query text alone and ignored the toggles entirely. The feed keys its
cache on the built query, since filters are part of a feed's identity.
Co-Authored-By: Claude Opus 5 <[email protected]>