Providers no longer return a `Response`. Each one says what it needs from
the person — an address, a pin, a yes-or-no — as a `Screen`, and a single
`Renderer` decides how that is drawn.
The old arrangement made every provider a small web framework. It had to
know about markup, about the stylesheet's attribute names, about how a page
is assembled, so each grew its own callback signature and its own copy of
`new Response(jsx.toString())`. Three consequences, all of them visible in
the tree before this change:
- The device flow never got a design at all. Its two pages were built by
concatenating HTML strings, with an inline `style` on the user code, and
six of its replies were `text/plain` — unstyled black-on-white in the
middle of signing in, which is also what a person got when their sign-in
cookie expired.
- The password screens were drifting. They were written against attribute
names the stylesheet no longer had, and nobody noticed because password
sign-in is not switched on. They are deleted here rather than repaired;
the flow is now six screen descriptions and no markup.
- A provider could not be named or marked without editing the library. The
brand marks and display names were two hardcoded records inside the code
that drew the chooser, so anything missing from them rendered as its own
lowercase identifier with no icon. Providers now declare `display`
themselves, and the chooser is built from what they say.
Also removes the theme global. It was `globalThis`, with a comment
conceding as much, which made every component depend on something invisible
at the call site — untestable in isolation, and shared mutable state on a
runtime that keeps one module instance across requests. The theme is now a
closure argument, and the same change shrinks `Theme` to the handful of
values a deployment sets that the stylesheet cannot.
Adding a screen now touches no CSS, and swapping the presentation layer
means implementing one method. The code flow's tests demonstrate the
second: they render screens as JSON.
Behaviour is unchanged. Status codes, cookies and the confirmation step are
the same, which the device tests cover unmodified.
The cap on how many codes a sign-in could ask for was held per attempt,
keyed by a value in the caller's own cookie. That bounds nothing. The
caller decides how many attempts to start, and starting a fresh one costs
them a discarded cookie — so either replaying an older cookie or simply
beginning again walked straight around it, and the only thing left
spacing the mail out was the interval between sends.
The count now sits against the claim, over a window. That is the thing
being protected: the mailbox belongs to somebody who did not ask to hear
from us, and whoever is pointing at it is not the party to trust with the
tally.
A resend also left the previous code live, with a budget of guesses of
its own. Several resends therefore meant several working codes and
several times the chances at them, which made asking for a new code the
cheapest way to buy more tries at the old one. A new code now retires the
one before it.
Reported against the replay path. The replay was real and the same hole
was wider than that: starting a new attempt needed no replay at all.
A six-digit code has a million values, and nothing was counting how many
of them a caller tried. The code travelled in an encrypted cookie the
caller held, verification compared against that cookie, and a wrong
answer simply re-rendered the form. Nobody has to be the person the code
was mailed to: type somebody else's address into the first screen and the
code goes to their mailbox while the cookie stays with you. At that point
the only thing between a stranger and an account is a million requests,
and the constant-time comparison protecting the code was guarding a door
you could just keep knocking on.
Guesses are now counted on the server, under a name that changes with
every code. That placement is the point: a counter kept beside the code,
in the cookie, is a counter the guesser can wind back by replaying an
older copy. Starting over is still allowed and still costs a fresh code
sent to the mailbox being aimed at, which is where somebody notices. A
correct code spends its record too, so its remaining guesses do not carry
into the next one.
The cookie also lived for twenty-four hours, which made the pin a
password with a million possible values and a day to try them. Ten
minutes now, and the code stops being accepted when the clock says so
rather than when the cookie happens to go away.
Resend had no limit either, so the button was a way to mail a stranger as
fast as requests go out. Codes to one address are spaced, and one attempt
at signing in can only ask for so many.
Both refusals say the same thing on purpose. Which of the two it was is a
fact about somebody else's mailbox.