Skip to content
runlot

Custom sign-in page

Draw your own sign-in screen, deep-link into Runlot ID, handle errors, brand the Runlot page, and test it locally.

Your app can own the sign-in experience before and after the Runlot screen, and decide what the Runlot screen looks like. The Runlot ID password is still typed only on a Runlot page: anything you draw yourself either links to Runlot or uses a project-local method.

/__runlot/auth/sign-in/runlot takes four optional parameters. Without them it behaves as before.

ParameterEffect
idpgoogle or github skips the chooser and goes straight to that provider. password opens the email form, passkey the passkey prompt. A method that is turned off is ignored and the chooser is shown.
login_hintPrefills the email on the Runlot form. It is never submitted automatically and nothing about the account comes back to your app.
error_nextWhere errors return to instead of next. A same-origin path, like next.
promptnone signs the person in without any page when they are already signed in to Runlot and have used your app before; otherwise it returns login_required.
import { signInUrl } from "@runlot/auth/client";

signInUrl("runlot", { idp: "google", next: "/chat" });
// /__runlot/auth/sign-in/runlot?next=%2Fchat&idp=google
signInUrl("runlot", { idp: "password", loginHint: "[email protected]", errorNext: "/login" });

idp, loginHint, errorNext and prompt are accepted only for "runlot" — passing them with another provider is a type error. config() tells you which methods are on: the runlot provider entry carries methods, for example ["password", "google", "github"].

Errors: auth_error

When sign-in does not succeed, the person comes back to error_next (or next) with two query parameters:

/login?auth_error=cancelled&auth_request=6f1c…
CodeMeaning
cancelledThe person went back or closed the provider screen
signup_closedSign-ups are off and there is no existing account
email_unverifiedVerified email is required and the provider's email is not verified
provider_unavailableThe method was turned off during sign-in
rate_limitedToo many attempts
expiredThe sign-in request is too old
consent_deniedThe person declined
login_requiredprompt=none and no usable session
account_existsLink by email is off and the email belongs to another account
server_errorAnything else; auth_request is what support looks up

The code never carries an email, a provider message or a stack. @runlot/auth exports the closed list and a default message in Korean, English and Japanese:

import { authErrorMessage, parseAuthError, stripAuthError } from "@runlot/auth/client";

const err = parseAuthError(location.href); // { code, request } | null
if (err) {
  show(authErrorMessage(err.code, "ko"));
  stripAuthError(); // removes auth_error/auth_request so a reload does not show it again
}

An unknown code is reported as server_error.

<runlot-sign-in>

A dependency-free web component that draws the enabled Runlot methods as links, shows an auth_error message and then strips it from the address bar. It is about 4 KiB gzipped.

<script type="module">import "@runlot/auth/ui";</script>
<runlot-sign-in next="/chat" lang="ko" methods="google github password" guest error-next="/login"></runlot-sign-in>
Attribute
next, error-next, login-hintPassed to every link
langko (default), en or ja
methodsWhich buttons to draw, space-separated; runlot is the plain chooser button. Defaults to what the project offers, and a method the project turned off is never drawn
guestAdds a guest button that fires a runlot-guest event

Every element has a part, so you can style it from outside: runlot-sign-in::part(button-google), ::part(error), ::part(root). Colours, radius and font come from CSS custom properties: --runlot-brand, --runlot-brand-text, --runlot-bg, --runlot-border, --runlot-radius, --runlot-font, --runlot-gap, --runlot-error. The header and footer slots take your own markup.

React:

import { RunlotSignIn } from "@runlot/auth/react";

<RunlotSignIn next="/chat" lang="ko" methods={["google", "github"]} guest onGuest={() => enterAsGuest()} />

JSON API for project-local methods

If you want the form inside your own page, use the project's own password and email-link methods through JSON. These accounts belong to your project only; they are not Runlot ID accounts.

import { signInWithPassword, signUp, requestReset, confirmReset, sendMagicLink } from "@runlot/auth/client";

const r = await signInWithPassword({ email, password });
if (r.ok) location.href = "/";
else show(authErrorMessage(r.error, "ko")); // invalid_credentials, rate_limited, …
RouteBody
POST /__runlot/auth/api/password/sign-in{email, password} → 200 {user} and the session cookie
POST /__runlot/auth/api/password/sign-up{email, password, name?}
POST /__runlot/auth/api/password/reset{email} → always 202
POST /__runlot/auth/api/password/reset/confirm{token, password}
POST /__runlot/auth/api/magic-link{email, next} → always 202

Requests must be Content-Type: application/json (otherwise 415) and come from your app's origin (otherwise 403 csrf). Errors are the codes above plus invalid_credentials, weak_password and check_email. A 429 carries Retry-After.

Branding the Runlot page

The Runlot chooser can carry your copy, layout and colours. All fields are optional; the defaults reproduce the plain page. Branding is data, validated by the server — there is no custom HTML or CSS.

runlot auth set brand-copy ko.title "세법 원문을 근거로 답합니다"
runlot auth set brand-layout split            # centered | split | minimal
runlot auth set brand-theme dark              # light | dark | system
runlot auth set brand-button pill             # rounded | pill | square
runlot auth set brand-background color "#0b1020"   # none | color | gradient | image
runlot auth set brand-logo-dark https://example.com/logo-dark.svg
runlot auth set brand-link privacy https://example.com/privacy
runlot auth set brand-lang ko                 # ko | en | ja | auto
runlot auth set brand-methods google,github   # all = every available method

Copy is limited to 80 characters for the title and 200 for the subtitle and the footer, per language. Links and images must be https URLs.

To manage the whole object as a file:

runlot auth brand get > branding.json
runlot auth brand apply branding.json

An invalid file is refused with 400 invalid_branding and the path of each bad field. The dashboard's Sign-in & access tab has the same form with a live preview.

Preview

runlot auth preview                       # open the Runlot page with your branding
runlot auth preview --lang en --theme dark --state error:cancelled

--state is password, passkey or error:<code>. The preview needs no real sign-in.

Local development: runlot dev --auth=chooser

runlot dev --auth=chooser

/__runlot/auth/sign-in/runlot then answers with a local copy of the chooser in your branding. Pick a test identity to come back signed in, or press an error button to come back with any auth_error code. idp, login_hint, error_next and prompt behave as in production. The branding is read once from the API and cached in .runlot/auth-branding.json; delete that file to refresh it. Offline, the default look is used. Without the flag, runlot dev keeps signing in the fixed dev user.

Linking accounts and merging users

By default a Runlot sign-in whose verified email matches an existing user under another method is attached to that user. To keep them apart:

runlot auth set link-by-email off

Such a sign-in then returns auth_error=account_exists.

To join two users after the fact, for example after switching from your own Google app to Runlot:

runlot auth users merge <from-user-id> <into-user-id>

This moves every identity of <from> to <into>, signs <from> out and deletes it, in one transaction in the project database. Rows in your own tables that reference <from> are yours to update. From a worker, env.auth.users.merge(from, into) does the same.

The dashboard's sign-in tab also shows a 30-day funnel — starts, successes and each error code per day — and runlot auth funnel --days 30 prints the totals. No per-person data is kept.

On this page