Documentation
¶
Overview ¶
Package httpx contains the HTTP layer: routing, middleware and handlers.
Index ¶
- Constants
- func AsError(err error, target any) bool
- func BearerAuth(a Authenticator) func(http.Handler) http.Handler
- func ClearSessionCookie(secure bool) *http.Cookie
- func ClientIPFrom(ctx context.Context) netip.Addr
- func CustomDomainFrom(ctx context.Context) (redirect.VerifiedDomain, bool)
- func IdentityFrom(ctx context.Context) *auth.Identity
- func NewRouter(d Deps) http.Handler
- func NewSessionCookie(token string, secure bool, maxAge int) *http.Cookie
- func RateLimit(l *ratelimit.Limiter, name string, metrics *observability.Metrics, deny Deny) func(http.Handler) http.Handler
- func RealIP(trusted []netip.Prefix) func(http.Handler) http.Handler
- func RequestTimeout(d time.Duration) func(http.Handler) http.Handler
- func RequireAuth(next http.Handler) http.Handler
- func SecurityHeaders(cfg config.Config) func(http.Handler) http.Handler
- func ServerTiming(enabled bool) func(http.Handler) http.Handler
- func Session(a Authenticator, secure bool) func(http.Handler) http.Handler
- func WithCustomDomain(ctx context.Context, d redirect.VerifiedDomain) context.Context
- func WriteError(w http.ResponseWriter, r *http.Request, err error)
- func WriteJSON(w http.ResponseWriter, status int, v any)
- func WriteProblem(w http.ResponseWriter, r *http.Request, p Problem)
- type AccountAPI
- type AuditAPI
- type AuthAPI
- func (a *AuthAPI) ChangePassword(w http.ResponseWriter, r *http.Request)
- func (a *AuthAPI) Login(w http.ResponseWriter, r *http.Request)
- func (a *AuthAPI) Logout(w http.ResponseWriter, r *http.Request)
- func (a *AuthAPI) Register(w http.ResponseWriter, r *http.Request)
- func (a *AuthAPI) Setup(w http.ResponseWriter, r *http.Request)
- type Authenticator
- type ClickEvent
- type ClickRecorder
- type Deny
- type Deps
- type DisputeAPI
- type DocsHandlers
- type DomainRootRedirect
- type FeedAPI
- type Gatekeeper
- type GeoResolver
- type Health
- type InstanceAPI
- type InvitationAPI
- type KeyAPI
- type Limiters
- type LinkAPI
- func (a *LinkAPI) Archive(w http.ResponseWriter, r *http.Request)
- func (a *LinkAPI) Create(w http.ResponseWriter, r *http.Request)
- func (a *LinkAPI) CreateAutomationRule(w http.ResponseWriter, r *http.Request)
- func (a *LinkAPI) CreateCampaign(w http.ResponseWriter, r *http.Request)
- func (a *LinkAPI) CreateDomain(w http.ResponseWriter, r *http.Request)
- func (a *LinkAPI) CreateFolder(w http.ResponseWriter, r *http.Request)
- func (a *LinkAPI) CreateQRCode(w http.ResponseWriter, r *http.Request)
- func (a *LinkAPI) CreateRule(w http.ResponseWriter, r *http.Request)
- func (a *LinkAPI) CreateVariant(w http.ResponseWriter, r *http.Request)
- func (a *LinkAPI) CreateWebhook(w http.ResponseWriter, r *http.Request)
- func (a *LinkAPI) Delete(w http.ResponseWriter, r *http.Request)
- func (a *LinkAPI) DeleteAutomationRule(w http.ResponseWriter, r *http.Request)
- func (a *LinkAPI) DeleteCampaign(w http.ResponseWriter, r *http.Request)
- func (a *LinkAPI) DeleteFolder(w http.ResponseWriter, r *http.Request)
- func (a *LinkAPI) DeleteQR(w http.ResponseWriter, r *http.Request)
- func (a *LinkAPI) DeleteQRCode(w http.ResponseWriter, r *http.Request)
- func (a *LinkAPI) DeleteQRCodeLogo(w http.ResponseWriter, r *http.Request)
- func (a *LinkAPI) DeleteQRLogo(w http.ResponseWriter, r *http.Request)
- func (a *LinkAPI) DeleteRegisteredDomain(w http.ResponseWriter, r *http.Request)
- func (a *LinkAPI) DeleteRule(w http.ResponseWriter, r *http.Request)
- func (a *LinkAPI) DeleteTag(w http.ResponseWriter, r *http.Request)
- func (a *LinkAPI) DeleteVariant(w http.ResponseWriter, r *http.Request)
- func (a *LinkAPI) DeleteWebhook(w http.ResponseWriter, r *http.Request)
- func (a *LinkAPI) Get(w http.ResponseWriter, r *http.Request)
- func (a *LinkAPI) GetAutomationRule(w http.ResponseWriter, r *http.Request)
- func (a *LinkAPI) GetCampaign(w http.ResponseWriter, r *http.Request)
- func (a *LinkAPI) GetDomain(w http.ResponseWriter, r *http.Request)
- func (a *LinkAPI) GetQR(w http.ResponseWriter, r *http.Request)
- func (a *LinkAPI) GetQRCode(w http.ResponseWriter, r *http.Request)
- func (a *LinkAPI) GetQRCodePNG(w http.ResponseWriter, r *http.Request)
- func (a *LinkAPI) GetQRCodeSVG(w http.ResponseWriter, r *http.Request)
- func (a *LinkAPI) GetQRPNG(w http.ResponseWriter, r *http.Request)
- func (a *LinkAPI) GetQRSVG(w http.ResponseWriter, r *http.Request)
- func (a *LinkAPI) GetSplit(w http.ResponseWriter, r *http.Request)
- func (a *LinkAPI) GetWebhook(w http.ResponseWriter, r *http.Request)
- func (a *LinkAPI) List(w http.ResponseWriter, r *http.Request)
- func (a *LinkAPI) ListAutomationRules(w http.ResponseWriter, r *http.Request)
- func (a *LinkAPI) ListCampaigns(w http.ResponseWriter, r *http.Request)
- func (a *LinkAPI) ListDomains(w http.ResponseWriter, r *http.Request)
- func (a *LinkAPI) ListFolders(w http.ResponseWriter, r *http.Request)
- func (a *LinkAPI) ListQRCodes(w http.ResponseWriter, r *http.Request)
- func (a *LinkAPI) ListRules(w http.ResponseWriter, r *http.Request)
- func (a *LinkAPI) ListTags(w http.ResponseWriter, r *http.Request)
- func (a *LinkAPI) ListWebhookDeliveries(w http.ResponseWriter, r *http.Request)
- func (a *LinkAPI) ListWebhooks(w http.ResponseWriter, r *http.Request)
- func (a *LinkAPI) Me(w http.ResponseWriter, r *http.Request)
- func (a *LinkAPI) MoveFolder(w http.ResponseWriter, r *http.Request)
- func (a *LinkAPI) Restore(w http.ResponseWriter, r *http.Request)
- func (a *LinkAPI) RotateWebhookSecret(w http.ResponseWriter, r *http.Request)
- func (a *LinkAPI) SetDefaultQRCode(w http.ResponseWriter, r *http.Request)
- func (a *LinkAPI) SetDomainRootRedirect(w http.ResponseWriter, r *http.Request)
- func (a *LinkAPI) SetQR(w http.ResponseWriter, r *http.Request)
- func (a *LinkAPI) SetQRCode(w http.ResponseWriter, r *http.Request)
- func (a *LinkAPI) SetQRCodeLogo(w http.ResponseWriter, r *http.Request)
- func (a *LinkAPI) SetQRLogo(w http.ResponseWriter, r *http.Request)
- func (a *LinkAPI) Sign(w http.ResponseWriter, r *http.Request)
- func (a *LinkAPI) Update(w http.ResponseWriter, r *http.Request)
- func (a *LinkAPI) UpdateAutomationRule(w http.ResponseWriter, r *http.Request)
- func (a *LinkAPI) UpdateCampaign(w http.ResponseWriter, r *http.Request)
- func (a *LinkAPI) UpdateDomain(w http.ResponseWriter, r *http.Request)
- func (a *LinkAPI) UpdateFolder(w http.ResponseWriter, r *http.Request)
- func (a *LinkAPI) UpdateRegisteredDomain(w http.ResponseWriter, r *http.Request)
- func (a *LinkAPI) UpdateRule(w http.ResponseWriter, r *http.Request)
- func (a *LinkAPI) UpdateVariant(w http.ResponseWriter, r *http.Request)
- func (a *LinkAPI) UpdateWebhook(w http.ResponseWriter, r *http.Request)
- func (a *LinkAPI) VerifyDomain(w http.ResponseWriter, r *http.Request)
- type MFAAPI
- func (a *MFAAPI) Challenge(w http.ResponseWriter, r *http.Request)
- func (a *MFAAPI) Confirm(w http.ResponseWriter, r *http.Request)
- func (a *MFAAPI) Disable(w http.ResponseWriter, r *http.Request)
- func (a *MFAAPI) Enrol(w http.ResponseWriter, r *http.Request)
- func (a *MFAAPI) RegenerateRecoveryCodes(w http.ResponseWriter, r *http.Request)
- func (a *MFAAPI) Status(w http.ResponseWriter, r *http.Request)
- type NotificationAPI
- func (a *NotificationAPI) List(w http.ResponseWriter, r *http.Request)
- func (a *NotificationAPI) MarkUnread(w http.ResponseWriter, r *http.Request)
- func (a *NotificationAPI) Read(w http.ResponseWriter, r *http.Request)
- func (a *NotificationAPI) ReadAll(w http.ResponseWriter, r *http.Request)
- func (a *NotificationAPI) Unread(w http.ResponseWriter, r *http.Request)
- type Problem
- type RecoveryAPI
- type RedirectHandler
- type Report
- type RootRedirect
- type StatsAPI
- type TLSAsk
- type TeamAPI
- func (a *TeamAPI) ChangeMemberRole(w http.ResponseWriter, r *http.Request)
- func (a *TeamAPI) CreateOrganization(w http.ResponseWriter, r *http.Request)
- func (a *TeamAPI) CreateWorkspace(w http.ResponseWriter, r *http.Request)
- func (a *TeamAPI) DeleteOrganization(w http.ResponseWriter, r *http.Request)
- func (a *TeamAPI) DeleteWorkspace(w http.ResponseWriter, r *http.Request)
- func (a *TeamAPI) GrantMember(w http.ResponseWriter, r *http.Request)
- func (a *TeamAPI) ListMembers(w http.ResponseWriter, r *http.Request)
- func (a *TeamAPI) RemoveMember(w http.ResponseWriter, r *http.Request)
- func (a *TeamAPI) RenameWorkspace(w http.ResponseWriter, r *http.Request)
- type Web
- func (h *Web) AccountDelete(w http.ResponseWriter, r *http.Request)
- func (h *Web) AccountPage(w http.ResponseWriter, r *http.Request)
- func (h *Web) AutomationCreate(w http.ResponseWriter, r *http.Request)
- func (h *Web) AutomationDelete(w http.ResponseWriter, r *http.Request)
- func (h *Web) AutomationPage(w http.ResponseWriter, r *http.Request)
- func (h *Web) AutomationToggle(w http.ResponseWriter, r *http.Request)
- func (h *Web) AutomationUpdate(w http.ResponseWriter, r *http.Request)
- func (h *Web) BotBlockingUpdate(w http.ResponseWriter, r *http.Request)
- func (h *Web) CampaignCreate(w http.ResponseWriter, r *http.Request)
- func (h *Web) CampaignDelete(w http.ResponseWriter, r *http.Request)
- func (h *Web) CampaignUpdate(w http.ResponseWriter, r *http.Request)
- func (h *Web) CampaignsPage(w http.ResponseWriter, r *http.Request)
- func (h *Web) Dashboard(w http.ResponseWriter, r *http.Request)
- func (h *Web) DisputeAllow(w http.ResponseWriter, r *http.Request)
- func (h *Web) DisputeFile(w http.ResponseWriter, r *http.Request)
- func (h *Web) DisputeReviewerGrant(w http.ResponseWriter, r *http.Request)
- func (h *Web) DisputeReviewerRevoke(w http.ResponseWriter, r *http.Request)
- func (h *Web) DisputeReviewersPage(w http.ResponseWriter, r *http.Request)
- func (h *Web) DisputeUphold(w http.ResponseWriter, r *http.Request)
- func (h *Web) DisputesPage(w http.ResponseWriter, r *http.Request)
- func (h *Web) DomainCreate(w http.ResponseWriter, r *http.Request)
- func (h *Web) DomainDelete(w http.ResponseWriter, r *http.Request)
- func (h *Web) DomainRename(w http.ResponseWriter, r *http.Request)
- func (h *Web) DomainRootRedirect(w http.ResponseWriter, r *http.Request)
- func (h *Web) DomainUpdate(w http.ResponseWriter, r *http.Request)
- func (h *Web) DomainVerify(w http.ResponseWriter, r *http.Request)
- func (h *Web) DomainsPage(w http.ResponseWriter, r *http.Request)
- func (h *Web) FeedsPage(w http.ResponseWriter, r *http.Request)
- func (h *Web) FolderCreate(w http.ResponseWriter, r *http.Request)
- func (h *Web) FolderDelete(w http.ResponseWriter, r *http.Request)
- func (h *Web) FolderMove(w http.ResponseWriter, r *http.Request)
- func (h *Web) FolderRename(w http.ResponseWriter, r *http.Request)
- func (h *Web) FoldersPage(w http.ResponseWriter, r *http.Request)
- func (h *Web) ForgotPage(w http.ResponseWriter, r *http.Request)
- func (h *Web) ForgotSubmit(w http.ResponseWriter, r *http.Request)
- func (h *Web) InviteAccept(w http.ResponseWriter, r *http.Request)
- func (h *Web) InviteCreate(w http.ResponseWriter, r *http.Request)
- func (h *Web) InvitePage(w http.ResponseWriter, r *http.Request)
- func (h *Web) InviteRevoke(w http.ResponseWriter, r *http.Request)
- func (h *Web) InvitesPage(w http.ResponseWriter, r *http.Request)
- func (h *Web) KeyCreate(w http.ResponseWriter, r *http.Request)
- func (h *Web) KeyRevoke(w http.ResponseWriter, r *http.Request)
- func (h *Web) KeysPage(w http.ResponseWriter, r *http.Request)
- func (h *Web) LinkArchive(w http.ResponseWriter, r *http.Request)
- func (h *Web) LinkCreate(w http.ResponseWriter, r *http.Request)
- func (h *Web) LinkDelete(w http.ResponseWriter, r *http.Request)
- func (h *Web) LinkDetail(w http.ResponseWriter, r *http.Request)
- func (h *Web) LinkQRLogo(w http.ResponseWriter, r *http.Request)
- func (h *Web) LinkQRPage(w http.ResponseWriter, r *http.Request)
- func (h *Web) LinkQRStyle(w http.ResponseWriter, r *http.Request)
- func (h *Web) LinkRestore(w http.ResponseWriter, r *http.Request)
- func (h *Web) LinkSign(w http.ResponseWriter, r *http.Request)
- func (h *Web) LinkUpdate(w http.ResponseWriter, r *http.Request)
- func (h *Web) LinksPage(w http.ResponseWriter, r *http.Request)
- func (h *Web) LoginPage(w http.ResponseWriter, r *http.Request)
- func (h *Web) LoginSubmit(w http.ResponseWriter, r *http.Request)
- func (h *Web) Logout(w http.ResponseWriter, r *http.Request)
- func (h *Web) MFAChallengePage(w http.ResponseWriter, r *http.Request)
- func (h *Web) MFAChallengeSubmit(w http.ResponseWriter, r *http.Request)
- func (h *Web) MFADisable(w http.ResponseWriter, r *http.Request)
- func (h *Web) MFAEnrol(w http.ResponseWriter, r *http.Request)
- func (h *Web) MFAPage(w http.ResponseWriter, r *http.Request)
- func (h *Web) MFARegenerate(w http.ResponseWriter, r *http.Request)
- func (h *Web) MemberGrant(w http.ResponseWriter, r *http.Request)
- func (h *Web) MemberRemove(w http.ResponseWriter, r *http.Request)
- func (h *Web) MemberRole(w http.ResponseWriter, r *http.Request)
- func (h *Web) MembersPage(w http.ResponseWriter, r *http.Request)
- func (h *Web) NotificationOpen(w http.ResponseWriter, r *http.Request)
- func (h *Web) NotificationRead(w http.ResponseWriter, r *http.Request)
- func (h *Web) NotificationReadAll(w http.ResponseWriter, r *http.Request)
- func (h *Web) NotificationUnread(w http.ResponseWriter, r *http.Request)
- func (h *Web) NotificationsPage(w http.ResponseWriter, r *http.Request)
- func (h *Web) OrganizationCreate(w http.ResponseWriter, r *http.Request)
- func (h *Web) OrganizationDelete(w http.ResponseWriter, r *http.Request)
- func (h *Web) OrganizationNewPage(w http.ResponseWriter, r *http.Request)
- func (h *Web) PasswordChange(w http.ResponseWriter, r *http.Request)
- func (h *Web) RequireOrganization(next http.Handler) http.Handler
- func (h *Web) RequireWebAuth(next http.Handler) http.Handler
- func (h *Web) ResetPage(w http.ResponseWriter, r *http.Request)
- func (h *Web) ResetSubmit(w http.ResponseWriter, r *http.Request)
- func (h *Web) Root(w http.ResponseWriter, r *http.Request)
- func (h *Web) RuleCreate(w http.ResponseWriter, r *http.Request)
- func (h *Web) RuleDelete(w http.ResponseWriter, r *http.Request)
- func (h *Web) RuleToggle(w http.ResponseWriter, r *http.Request)
- func (h *Web) SetupPage(w http.ResponseWriter, r *http.Request)
- func (h *Web) SetupSubmit(w http.ResponseWriter, r *http.Request)
- func (h *Web) SignupPage(w http.ResponseWriter, r *http.Request)
- func (h *Web) SignupSubmit(w http.ResponseWriter, r *http.Request)
- func (h *Web) ThemeSet(w http.ResponseWriter, r *http.Request)
- func (h *Web) UpdateCheckAnswer(w http.ResponseWriter, r *http.Request)
- func (h *Web) VariantCreate(w http.ResponseWriter, r *http.Request)
- func (h *Web) VariantDelete(w http.ResponseWriter, r *http.Request)
- func (h *Web) VariantToggle(w http.ResponseWriter, r *http.Request)
- func (h *Web) VerifyPage(w http.ResponseWriter, r *http.Request)
- func (h *Web) VerifySubmit(w http.ResponseWriter, r *http.Request)
- func (h *Web) WebhookCreate(w http.ResponseWriter, r *http.Request)
- func (h *Web) WebhookDelete(w http.ResponseWriter, r *http.Request)
- func (h *Web) WebhookRotate(w http.ResponseWriter, r *http.Request)
- func (h *Web) WebhookToggle(w http.ResponseWriter, r *http.Request)
- func (h *Web) WebhookUpdate(w http.ResponseWriter, r *http.Request)
- func (h *Web) WebhooksPage(w http.ResponseWriter, r *http.Request)
- func (h *Web) WorkspaceCreate(w http.ResponseWriter, r *http.Request)
- func (h *Web) WorkspaceDefault(w http.ResponseWriter, r *http.Request)
- func (h *Web) WorkspaceDelete(w http.ResponseWriter, r *http.Request)
- func (h *Web) WorkspaceRename(w http.ResponseWriter, r *http.Request)
- func (h *Web) WorkspaceSwitch(w http.ResponseWriter, r *http.Request)
- func (h *Web) WorkspacesPage(w http.ResponseWriter, r *http.Request)
- type WorkspaceAPI
Constants ¶
const ( MaxForwardedPath = 4096 MaxForwardedSegments = 64 )
joinable reports whether a remainder may be appended at all.
Dot segments are refused rather than resolved. A browser normalizes them before it asks for anything — and the URL standard counts "%2e" and "%2E" as dots too, which is how one reaches us at all: ServeMux cleans the escaped path and redirects, so the literal spellings never arrive and only the encoded ones do.
Refusing is the whole point. Resolving would let /{alias}/../../secret walk out of the subtree the owner pointed at, and silently dropping the segments would send the visitor somewhere they did not ask for while looking like it worked. A 404 says what happened. MaxForwardedPath and MaxForwardedSegments bound a deep-link remainder.
With forward_path on, everything after the alias is visitor-controlled and nothing between the router and the joiner looked at its size: `MaxHeaderBytes` is unset, so the ceiling was net/http's 1 MiB default, and a cache hit is deliberately never charged to the 404-probe limiter. `joinable` walks every segment calling `url.PathUnescape`, then the concatenation, the unescape of the join and `u.String()` each copy the whole remainder — roughly five passes over up to a megabyte, on a request that touches no database (F116).
**Both bounds, because either alone misses it.** Length alone leaves the per-segment cost, which is driven by segment *count*: a 40 KiB path of twenty thousand empty segments is twenty thousand `PathUnescape` calls under any sane length cap. Count alone leaves one enormous segment.
Generous on purpose. The longest thing a real deep link forwards is a path a human or a CMS produced, and these are two orders of magnitude above that — this is an amplification bound, not a validation rule.
const ( ThemeSystem = "system" ThemeLight = "light" ThemeDark = "dark" )
Theme choices. "system" is the absence of a choice rather than a third appearance, and is stored by clearing the cookie.
const APIPrefix = "/api/v1"
APIPrefix is the versioned API root.
const DefaultActivateTimeout = 5 * time.Second
DefaultActivateTimeout bounds the /tls-check write.
This route is mounted by registerOps on four muxes, none of which pass through appHandler, which is where RequestTimeout lives — and neither pool sets a statement_timeout. Detaching the request context without adding a deadline therefore left the write bounded by nothing at all, on an unauthenticated endpoint (F129). Every other detached context on the redirect tree pairs the two calls; this was the exception.
const DefaultRootLoadTimeout = 2 * time.Second
DefaultRootLoadTimeout bounds one refill when LoadTimeout is zero.
It exists because this handler is mounted on the redirect mux, which deliberately has no `RequestTimeout` — that middleware lives in `appHandler`, and the redirect tree is outside it. So until F48 the only bound on this read was the server's 30s `WriteTimeout`, on the one redirect-tree path that talks to Postgres on a request.
const PNGDisposition = `attachment; filename="qr.png"`
PNGDisposition is what the PNG response asks a browser to do with the bytes.
**A filename that is not the link's alias, and that is deliberate.** The dashboard's anchor carries `download="qr-<alias>.png"`, which is where a workspace-controlled string belongs: in markup the template engine escapes. Putting it in a response header instead would be the one place in this product where workspace data is written into an HTTP header, for a filename the dashboard already overrides — so the header states the constant and the browser falls back to it only for somebody fetching the URL directly, who asked for `qr.png` and gets `qr.png`.
const SVGMaxAge = "private, max-age=300"
SVGMaxAge is how long a rendered code may be cached, in seconds.
Private, and short. A QR code changes when its link's alias changes or when somebody restyles it, and neither is frequent — but it is derived from a workspace's own data behind an authenticated request, so a shared cache must not keep it. Five minutes is enough for a dashboard page that renders the code twice, inline and in a download link.
const SignInRefusedDetail = "The email or password is incorrect. Repeated failures " +
"lock an account for a while; if this one is yours, wait a few minutes before " +
"trying again."
SignInRefusedDetail is the body every failed sign-in gets, on both surfaces.
One string rather than two so the API and the form cannot drift into saying different things, which is half of what finding F92 was: the API's answer and the browser's prose disagreed about whether an account existed, and fixing either alone leaves the other one answering.
**The lockout is named unconditionally**, and that is the point of the second sentence rather than a hedge. It is identical whether or not the address is registered and whether or not a lockout is in force, so it discloses nothing — while somebody certain they typed their own password correctly is told why waiting is the thing that helps. Without it, the price of closing the oracle is that a real user spends their own lockout being told they cannot type.
const TLSAskPath = "/tls-check"
TLSAskPath is where Caddy's on-demand TLS `ask` is answered.
A top-level path, so it is in internal/alias/reserved.txt like every other route: an alias called `tls-check` would otherwise shadow the endpoint that decides which hostnames get certificates, and TestReservedListCoversRegisteredRoutes is what enforces that it cannot.
Variables ¶
This section is empty.
Functions ¶
func BearerAuth ¶
func BearerAuth(a Authenticator) func(http.Handler) http.Handler
BearerAuth attaches an identity from an `Authorization: Bearer` API key.
Unlike Session it does reject, and the asymmetry is deliberate. A cookie that no longer resolves is an ordinary event — an expired login — so the request continues anonymously and whatever it reaches decides. A bearer token is an explicit, deliberate credential: continuing anonymously would answer a revoked key with "authentication required", which reads as "the endpoint needs auth" rather than "your key is dead", and sends the caller looking in the wrong place.
Runs before Session, so a request carrying both uses the key.
func ClearSessionCookie ¶
ClearSessionCookie expires the session cookie. Attributes must match the original or the browser will not replace it.
func ClientIPFrom ¶
ClientIPFrom returns the resolved client address.
The value itself is carried by internal/auth, so the service layer can read it without importing the HTTP layer — an audit event records the network a change came from, and the services that write those events are below this package. This stays as the name the handlers here already use.
func CustomDomainFrom ¶ added in v0.2.0
func CustomDomainFrom(ctx context.Context) (redirect.VerifiedDomain, bool)
CustomDomainFrom returns the verified domain this request arrived on, if any.
Absent on every request to the instance's own hosts, which is every request on an instance with no custom domains — so the default redirect path pays one type assertion against a nil interface value and allocates nothing.
func IdentityFrom ¶
IdentityFrom returns the authenticated identity, or nil.
func NewSessionCookie ¶
NewSessionCookie builds the session cookie.
The __Host- prefix requires Secure, Path=/ and no Domain, and browsers enforce that: a cookie with the prefix and any of those wrong is silently discarded. SameSite=Lax rather than Strict so following a link to the dashboard from elsewhere does not appear signed out, which users read as a bug.
func RateLimit ¶
func RateLimit(l *ratelimit.Limiter, name string, metrics *observability.Metrics, deny Deny) func(http.Handler) http.Handler
RateLimit throttles requests by client address.
A nil limiter returns the handler untouched rather than a wrapper that always allows, so a disabled limit costs nothing at all — not even a context lookup per request.
The address comes from RealIP, which trusts X-Forwarded-For only from configured proxies. That matters more here than anywhere else: behind a proxy with TRUSTED_PROXIES unset, every request carries the proxy's address, all traffic shares one bucket, and the limit applies to the whole world at once.
func RealIP ¶
RealIP resolves the client address, honouring X-Forwarded-For only from trusted proxies.
The trust list defaults to empty, and that default is the important part. A service that believes X-Forwarded-For unconditionally lets any client claim any address, which defeats rate limiting and corrupts analytics — and the mistake is invisible until someone abuses it.
func RequestTimeout ¶
RequestTimeout bounds how long a request may spend in the application tree.
A context deadline rather than http.TimeoutHandler, deliberately. The stdlib handler buffers the entire response in memory so it can replace it with a 503, which is a real cost on every request to gain a guarantee this service does not need: every database call here takes a context, so the deadline is what actually stops the work. What arrives at the client is then a 504 from the error mapper rather than a fabricated one from middleware.
The redirect tree is deliberately not wrapped. It has its own, much shorter budget — REDIRECT_TIMEOUT, applied where the resolver would touch Postgres — and a 15-second ceiling would be meaningless there.
A duration of zero disables it, returning the handler untouched.
func RequireAuth ¶
RequireAuth rejects requests with no identity.
func SecurityHeaders ¶
SecurityHeaders sets the defensive headers every response carries.
func ServerTiming ¶
ServerTiming emits a Server-Timing header carrying the server's own view of how long a response took.
Off by default, and that is a security default rather than a performance one: the header publishes internal timings to anyone who asks, and on a service where the interesting question is "does this alias exist" a timing difference is an answer. It is a development and debugging aid.
What it measures is the interval from entering this middleware to the handler deciding a status code — time to headers, not time to last byte. A header cannot be set after the response has started, so the alternative would be trailers, which no browser surfaces in the place a reader would look.
Never applied to the redirect tree: measuring it would mean instrumenting the path whose entire budget is 20ms, and the histogram already measures it more precisely.
func Session ¶
Session attaches an identity when a valid session cookie is present.
It never rejects: an anonymous request continues with no identity, and RequireAuth decides. Splitting the two keeps endpoints that behave differently for signed-in users from needing a second lookup.
func WithCustomDomain ¶ added in v0.2.0
WithCustomDomain marks a request as being for a verified custom hostname.
func WriteError ¶
func WriteError(w http.ResponseWriter, r *http.Request, err error)
WriteError maps a service error to a problem document.
The mapping lives here and only here, which is what lets services return domain sentinels without knowing anything about HTTP. It is also the single place that decides what a client is allowed to learn: unrecognised errors become a flat 500 with the detail logged rather than returned, because an internal error string can carry table names, query fragments, or a DSN.
func WriteJSON ¶
func WriteJSON(w http.ResponseWriter, status int, v any)
WriteJSON emits a success response.
func WriteProblem ¶
func WriteProblem(w http.ResponseWriter, r *http.Request, p Problem)
WriteProblem emits a problem document.
Types ¶
type AccountAPI ¶ added in v0.3.0
AccountAPI serves the account's own lifecycle (M52).
One operation, and it is the one the product never had: `users` appeared in none of the schema's DELETE statements and `anonymized_at` had no writer, while five places described erasure in the present tense (F44).
func (*AccountAPI) Delete ¶ added in v0.3.0
func (a *AccountAPI) Delete(w http.ResponseWriter, r *http.Request)
Delete removes the acting account.
**`DELETE` with a body**, which is unusual and is the right shape here. The confirmation is the account's own password, and a password does not belong in a query string, where it lands in access logs, browser history and referrers. RFC 9110 permits a body on DELETE and says only that it has no defined semantics; the semantics are defined here.
204 rather than 200. There is nothing to return: the caller's session is gone with the account, and the cookie is expired on the way out for the reason Logout expires it unconditionally — a browser that keeps sending a dead session gets a 401 on every request until something clears it.
type AuditAPI ¶ added in v0.2.0
AuditAPI exposes the audit log.
Read-only, and there is no write endpoint by design: events are a consequence of actions taken elsewhere, and an API that could post one would make the log a thing a caller asserts rather than a thing the system observed.
func (*AuditAPI) List ¶ added in v0.2.0
func (a *AuditAPI) List(w http.ResponseWriter, r *http.Request)
List answers one page of the audit records the caller's own authority covers in their organization.
Authorization is the ordinary permission check in the service — audit.read — with nothing here about which credential the caller used. That the permission is not delegable to an API key is enforced once, in auth.NonDelegableScopes, so no key can ever hold it and this handler never has to ask.
func (*AuditAPI) ListInstance ¶ added in v0.2.0
func (a *AuditAPI) ListInstance(w http.ResponseWriter, r *http.Request)
ListInstance answers one page of the instance-wide audit records — the acts that belong to the instance rather than to any tenant (F36).
Gated on audit.read.instance in the service, which only the instance principal holds (D98). A separate endpoint rather than a query parameter on the one above, because it is a separate authorization and a separate result set: a flag on a list is a thing a client sets by accident, and this one would silently change which permission the request needed.
type AuthAPI ¶
type AuthAPI struct {
Auth *auth.Service
// Signup owns registration since M29: whether this instance accepts one at
// all, and the two halves of an accepted one. Nil refuses every
// registration, which is the direction an unwired dependency has to fail in.
Signup *signup.Service
// Instance records the first-run answer to the update-check prompt (M55).
// Nil leaves the field on POST /auth/setup accepted and inert, which is the
// same state the dashboard's checkbox is in on a deployment wired without
// this service.
Instance *instance.Service
Config config.Config
}
AuthAPI serves the JSON authentication endpoints.
The API surface comes first, before the HTML forms in M11, because Plan.md makes "every UI feature has API support" a success criterion. Building the form first and retrofitting an endpoint is how that criterion gets broken; the forms will post to these same service calls.
func (*AuthAPI) ChangePassword ¶
func (a *AuthAPI) ChangePassword(w http.ResponseWriter, r *http.Request)
func (*AuthAPI) Register ¶
func (a *AuthAPI) Register(w http.ResponseWriter, r *http.Request)
Register starts a public registration, subject to the instance's effective signup mode.
`invite` is not open registration and never was: it admits accounts through POST /invitations/redeem, where an administrator named the address first. This endpoint is *public* signup, so anything but `open` refuses here.
202 rather than 201, and the change is the milestone's point. Nothing exists when this returns — no user, no organization, no workspace — because under D1 the address is proven before the account is usable, and the strongest form of that is an account which does not exist yet. The verification link creates it.
type Authenticator ¶
type Authenticator interface {
Authenticate(ctx context.Context, token string) (*auth.Identity, error)
}
Authenticator resolves credentials to an identity.
type ClickEvent ¶
type ClickEvent struct {
LinkID uuid.UUID
WorkspaceID uuid.UUID
OccurredAt time.Time
IP string
UserAgent string
Referrer string
Language string
LatencyUS int32
// Source is the resolved `?src=` value (M41), empty for every request that
// did not carry a recognised one. When set it replaces the referrer host on
// the stored click — a QR scan sends no Referer, so the column is otherwise
// empty and the click is indistinguishable from a typed URL.
Source string
// TrackReturning asks the click pipeline to remember this visitor in the
// within-day returning-visitor set (M34).
TrackReturning bool
// DestinationID is the destinations row this click was sent to (M36). The
// zero uuid means the link's own destination, which is where every click on
// a link with no rules and no split goes.
DestinationID uuid.UUID
}
type ClickRecorder ¶
type ClickRecorder interface {
Record(ev ClickEvent)
}
ClickRecorder accepts a click for asynchronous recording.
Deliberately returns nothing. Recording must never fail a redirect, never block it, and never be waited on.
type Deny ¶
type Deny func(http.ResponseWriter, *http.Request)
Deny answers a request that a rate limit refused. Retry-After is already set.
A function rather than a fixed response, because the same limiter guards two surfaces: a refused API call should be a problem document, and a refused form post should be a page a person can read.
type Deps ¶
type Deps struct {
Config config.Config
Health *Health
Auth *auth.Service
Keys *auth.APIKeyService
Links *link.Service
Redirect *RedirectHandler
// RootRedirect serves the link host's root. Only consulted on a split-host
// deployment; nil leaves that root a 404, which is what it was before the
// setting existed.
RootRedirect *RootRedirect
Stats *analytics.Reader
// Audit serves the audit log. Nil leaves the endpoint unregistered, which
// is what the parity test against openapi.yaml compares itself to.
Audit *audit.Service
// Notify serves the per-user inbox, and backs the nav's unread count.
Notify *notify.Service
// Invites serves the invitation lifecycle. Nil leaves both the endpoints
// and the dashboard page unregistered, which is what the parity test
// against openapi.yaml compares itself to.
Invites *invite.Service
// Team serves member management, workspace lifecycle and the organization
// lifecycle. Nil leaves its endpoints and its three dashboard pages
// unregistered, which is what the parity test against openapi.yaml compares
// itself to.
Team *team.Service
// Signup owns whether the instance accepts new accounts. Nil leaves the
// public signup pages unregistered and every registration refused, which is
// the direction a missing dependency has to fail in.
Signup *signup.Service
// Recovery repairs a forgotten password (M51). Nil leaves the two endpoints
// and the two public pages unregistered, which is what the parity test
// against openapi.yaml compares itself to — and which is the state F141
// describes: no route back into an account whose password was lost.
Recovery *recovery.Service
// Accounts ends an account's life and erases what ending it leaves behind
// (M52). Nil leaves the endpoint and the dashboard's delete section
// unregistered, which is what the parity test against openapi.yaml compares
// itself to — and which is the state F44 describes: no account deletion of
// any kind, for anybody.
Accounts *account.Service
// MFA owns the second factor (M53). Nil leaves its endpoints, its enrolment
// page and the code prompt unregistered, which is what the parity test
// against openapi.yaml compares itself to — and which is the state every
// instance was in before this milestone.
MFA *auth.MFAService
// Disputes serves the blocked-attempt appeal path and the review queue. Nil
// leaves both the endpoints and the dashboard page unregistered, which is
// what the parity test against openapi.yaml compares itself to — and which
// also takes the "ask for a review" button off the links form, so a refusal
// never offers a door that is not there.
Disputes *dispute.Service
// Instance serves the instance-level principal's roster (D98). Nil leaves
// the endpoints and the reviewer section of the dispute page unregistered,
// which is what the parity test against openapi.yaml compares itself to —
// and which leaves whoever the principal already is holding what they hold,
// because the grants are rows rather than a running service.
Instance *instance.Service
Web *Web
// Hosts is the verified custom-hostname set (M40). Nil leaves custom domains
// unrouted entirely — every Host header is answered exactly as it was before
// this milestone — which is what the CLI and the tests that predate it get.
//
// It is the *only* thing that decides whether an alias resolves on a name
// this operator did not configure, and it is populated by one query that
// filters on `verified_at`.
Hosts *redirect.HostCache
// DomainRoot serves a verified custom hostname's own root. Nil leaves that
// root a 404, which is where every custom hostname starts.
DomainRoot *DomainRootRedirect
// TLSAsk answers Caddy's on-demand TLS question. Nil leaves the endpoint
// unregistered, and then an operator's on-demand TLS block has nothing to
// ask — which is the correct failure, because issuing without the ask would
// obtain certificates for names nobody has verified.
TLSAsk *TLSAsk
// Metrics is optional. Nil disables instrumentation entirely rather than
// registering into a global registry, so two servers in one test process
// cannot collide.
Metrics *observability.Metrics
// Limits are the rate limits. The zero value enforces none, so a test that
// does not care about throttling does not have to opt out of it.
Limits Limiters
// Authenticator overrides how session cookies are resolved. Production
// leaves it nil and the auth service is used. The test that proves the
// redirect path performs no session lookup substitutes a tripwire here.
Authenticator Authenticator
}
Deps are the collaborators the router needs. An explicit struct so adding a dependency is a visible change rather than a hidden global.
type DisputeAPI ¶ added in v0.2.0
DisputeAPI exposes the blocked-attempt appeal path and the review queue.
**Nothing here fetches the destination it is handling.** Not to preview it, not to screenshot it, not to check whether it still resolves. A preview fetch would be exactly the SSRF the destination validator exists to refuse, arriving as a convenience feature — and this file is one of the ones TestTheQueueFetchesNothing parses to make sure it stays true.
Nothing here defangs either, and that is not an omission: the service returns a Dispute whose destination and host are already inert, so no handler and no template is the place where remembering has to happen.
func (*DisputeAPI) Allow ¶ added in v0.2.0
func (a *DisputeAPI) Allow(w http.ResponseWriter, r *http.Request)
Allow lifts the blocklist entry that refused the destination.
func (*DisputeAPI) File ¶ added in v0.2.0
func (a *DisputeAPI) File(w http.ResponseWriter, r *http.Request)
File opens a dispute about a destination that was refused.
The tier is re-derived from the URL inside the service. There is deliberately no field here naming a refusal, a reason code or an audit record: a caller who could name the tier could claim the appealable one, which is the whole of what "creatable only from a low-confidence refusal" has to prevent.
func (*DisputeAPI) List ¶ added in v0.2.0
func (a *DisputeAPI) List(w http.ResponseWriter, r *http.Request)
List answers one page of the queue, newest first.
func (*DisputeAPI) Uphold ¶ added in v0.2.0
func (a *DisputeAPI) Uphold(w http.ResponseWriter, r *http.Request)
Uphold leaves the refusal standing.
type DocsHandlers ¶
DocsHandlers serves the API reference: Swagger UI at /docs and the OpenAPI document under the API prefix.
Swagger UI rather than a lighter viewer because the plan promised it and its try-it-out console genuinely earns its megabyte on a self-hosted product — paste an API key, exercise the API from the browser, no curl. The assets are vendored and checksum-pinned like htmx; the renderer serves them fingerprinted from the same embedded static tree as everything else.
func (*DocsHandlers) Page ¶
func (d *DocsHandlers) Page(w http.ResponseWriter, r *http.Request)
func (*DocsHandlers) SpecJSON ¶
func (d *DocsHandlers) SpecJSON(w http.ResponseWriter, r *http.Request)
SpecJSON serves the contract in the form tooling asks for.
func (*DocsHandlers) SpecYAML ¶
func (d *DocsHandlers) SpecYAML(w http.ResponseWriter, r *http.Request)
SpecYAML serves the contract as authored.
type DomainRootRedirect ¶ added in v0.2.0
type DomainRootRedirect struct {
// Status is the redirect code, following the instance's configured default
// exactly as the instance root does.
Status int
}
DomainRootRedirect serves the root of a verified custom hostname.
A separate handler from RootRedirect and not a variant of it, because the two answer from different places for different reasons. The instance root reads one row through a TTL cache, because it belongs to a domain nothing else describes. A custom domain's root travels inside the verified-hostname set this request has already been matched against — so there is no cache to miss, no load function to fail, and no second invalidation path to keep in step with the first.
func (*DomainRootRedirect) ServeHTTP ¶ added in v0.2.0
func (h *DomainRootRedirect) ServeHTTP(w http.ResponseWriter, r *http.Request)
type FeedAPI ¶ added in v0.2.0
FeedAPI is the JSON half of the disclosure.
func (*FeedAPI) Get ¶ added in v0.2.0
func (a *FeedAPI) Get(w http.ResponseWriter, r *http.Request)
Get answers what happens to the destinations the caller submits.
One operation, GET, and there will not be a second. The dashboard page it mirrors has no controls and accepts no POST (D40), and the inherited rule that every UI feature has API support cuts both ways: an API that could write here would be the settings surface D38 removed, reachable with a bearer token instead of a session.
Ungated beyond authentication, matching the page. What is being disclosed is what happens to the caller's own destinations — including, since M45, the workspace's own webhooks, which are the channel this answered nothing about while saying `{"enabled": false}` meant nothing leaves (F135). A key with no webhook scope still gets the answer, because the answer is about the caller's data rather than about the registry: it carries a count and no URL.
type Gatekeeper ¶ added in v0.2.0
type Gatekeeper interface {
VerifyPassword(ctx context.Context, linkID uuid.UUID, password string) (bool, error)
Consume(ctx context.Context, linkID, workspaceID uuid.UUID, limit int64) (bool, error)
// Budget reads a link's spend without adding to it, for HEAD.
//
// It was on the service before this handler asked for it — the dashboard
// reads it to show how much of a ceiling is gone — and it is here because
// the answer HEAD needs is "is this spent", which is the only question
// Consume cannot be asked without also answering it.
Budget(ctx context.Context, linkID uuid.UUID) (int64, *time.Time, error)
Secret(ctx context.Context, workspaceID uuid.UUID) ([]byte, error)
// Rotate advances a sequential split's durable counter (M36, D8).
//
// Here rather than in an interface of its own because it is the same table,
// the same service and the same nil check: a second dependency on this
// handler would be a second thing to wire and a second thing to forget,
// for one method that exists for exactly the reason the click budget does —
// a counter Redis cannot hold. The name of the interface is now slightly
// wider than "gatekeeper", and that is the cheaper of the two prices.
Rotate(ctx context.Context, linkID, workspaceID uuid.UUID) (int64, error)
// PeekRotation reads that counter without advancing it, for HEAD (F100).
//
// Budget's twin, and here for Budget's reason: HEAD must be answered with
// what a visitor would get and must not change it. A probe used to advance
// the rotation and re-phase every subsequent visitor's arm, with no click
// event written to explain the skew.
PeekRotation(ctx context.Context, linkID, workspaceID uuid.UUID) (int64, error)
}
Gatekeeper is what the redirect handler needs from internal/gate.
An interface at the consumer, so a test can substitute one without a database and so the handler cannot reach for anything else the gate service happens to expose.
type GeoResolver ¶ added in v0.2.0
type GeoResolver interface {
Country(netip.Addr) string
Region(netip.Addr) string
City(netip.Addr) string
}
GeoResolver is what routing rules need from a MaxMind database.
Three methods rather than the geoip package's whole Resolver, so the redirect tree does not depend on how geography is looked up and so a test can route on a city without a database on disk. It is deliberately a *wider* interface than analytics.CountryResolver: the click pipeline may ask for a country and nothing else, this path may ask for all three, and the difference between the two interfaces is where "region and city are resolvable and never stored" lives.
type Health ¶
type Health struct {
DB *pgxpool.Pool
Redis *goredis.Client
// contains filtered or unexported fields
}
Health serves the liveness and readiness endpoints.
func (*Health) Live ¶
func (h *Health) Live(w http.ResponseWriter, _ *http.Request)
Live handles GET /healthz.
Liveness answers exactly one question: is this process wedged? It deliberately touches neither Postgres nor Redis. If it did, a database outage would cause the orchestrator to kill and restart every replica simultaneously, which turns a recoverable dependency failure into a much worse outage.
func (*Health) Ready ¶
func (h *Health) Ready(w http.ResponseWriter, r *http.Request)
Ready handles GET /readyz: should traffic be routed here?
func (*Health) StartDraining ¶
func (h *Health) StartDraining()
StartDraining flips readiness to 503 so a load balancer deregisters this instance before the HTTP server stops accepting connections.
type InstanceAPI ¶ added in v0.2.0
InstanceAPI exposes the instance-level principal's roster: who holds instance-level review, and the two writes that change it.
Authorization is the ordinary permission check in the service — instance.admin — with nothing here about which credential the caller used. That permission sits in auth.NonDelegableScopes, so no key can ever hold it and these handlers never have to ask.
The principal's other surface, the instance-wide audit log, lives on AuditAPI beside the organization one: it is the same table, the same page shape and the same cursor vocabulary, and splitting it across two files would have been an invitation for the two to drift.
func (*InstanceAPI) GrantReviewer ¶ added in v0.2.0
func (a *InstanceAPI) GrantReviewer(w http.ResponseWriter, r *http.Request)
GrantReviewer confers instance-level review on an account.
func (*InstanceAPI) Reviewers ¶ added in v0.2.0
func (a *InstanceAPI) Reviewers(w http.ResponseWriter, r *http.Request)
Reviewers lists who holds instance-level review.
func (*InstanceAPI) RevokeReviewer ¶ added in v0.2.0
func (a *InstanceAPI) RevokeReviewer(w http.ResponseWriter, r *http.Request)
RevokeReviewer withdraws it.
type InvitationAPI ¶ added in v0.2.0
InvitationAPI is the invitation lifecycle, as a program sees it.
The dashboard's forms post at the handlers in web_invitations.go and both reach the same four service calls, so a client can issue, list, revoke and redeem exactly as a person can.
Three of the four need `members.write`, which is delegable (D28) under a cap on what a key may issue at (D43, auth.KeyIssuableRoles). The cap and not the rank ceiling is what keeps D18's second limb from applying, because redemption needs no credential at all — it is how somebody who has none acquires one, and that is precisely why an unbounded key-issued invitation was a way for a key to widen its reach into scopes no key may hold.
func (*InvitationAPI) Create ¶ added in v0.2.0
func (a *InvitationAPI) Create(w http.ResponseWriter, r *http.Request)
Create issues an invitation.
201 with the link in it, because that link is the only copy of the token that will ever exist — the same reason creating an API key answers with the key. `emailed` says whether a message was also queued; false is an instance with no relay configured, not a failure.
func (*InvitationAPI) List ¶ added in v0.2.0
func (a *InvitationAPI) List(w http.ResponseWriter, r *http.Request)
List returns the organization's invitations, newest first.
Not paginated. An organization's invitations are a handful of rows by construction, and a cursor here would be machinery for a page that cannot fill.
func (*InvitationAPI) Redeem ¶ added in v0.2.0
func (a *InvitationAPI) Redeem(w http.ResponseWriter, r *http.Request)
Redeem turns an invitation into a membership.
Unauthenticated on purpose: this is the endpoint by which somebody who has no credential acquires one. It carries the login rate limit rather than the API one, because it verifies a password.
No session is started. The caller knows the password they just supplied and POST /auth/login is one call away, and minting a cookie from an endpoint a non-browser client is the main user of would be a credential nobody asked for. The dashboard's own form does sign the person in, in its handler.
func (*InvitationAPI) Revoke ¶ added in v0.2.0
func (a *InvitationAPI) Revoke(w http.ResponseWriter, r *http.Request)
Revoke ends an invitation that has not been redeemed.
type KeyAPI ¶
type KeyAPI struct {
Keys *auth.APIKeyService
}
KeyAPI serves /api/v1/api-keys.
Thin, like the other handlers: every rule about who may mint a key and which scopes they may grant lives in the service, so the dashboard's key page in M11 inherits the same behaviour by calling the same methods.
func (*KeyAPI) Create ¶
func (a *KeyAPI) Create(w http.ResponseWriter, r *http.Request)
Create issues a key. The response carries the token, and it is the only response that ever will.
func (*KeyAPI) Rotate ¶ added in v0.2.0
func (a *KeyAPI) Rotate(w http.ResponseWriter, r *http.Request)
Rotate replaces the key that made the request.
The route is `POST /api-keys/rotate` and not `/api-keys/{id}/rotate`, which is the API saying out loud what the service enforces: a key rotates itself. An id in the path would read as "name the key to rotate", and every caller who tried would get a 403 for asking the question the URL invited.
type Limiters ¶
type Limiters struct {
// Login guards the endpoints that verify a credential.
Login *ratelimit.Limiter
// API guards everything under /api/v1.
API *ratelimit.Limiter
// NotFound throttles addresses probing for aliases that do not exist. It is
// enforced inside the redirect handler rather than by middleware, because
// only a miss may be charged and middleware cannot tell a miss from a hit
// without inspecting the response it is wrapping.
NotFound *ratelimit.Limiter
// LinkPassword throttles guesses at a link password (D54). Shared through
// Redis like Login, and enforced inside the redirect handler for the same
// reason NotFound is: only a submitted password may be charged, and the
// handler is the only thing that knows a request is one.
LinkPassword *ratelimit.Limiter
// BlockedAudit bounds how often one actor makes the *same* destination
// refusal write an audit row (F14).
//
// The odd one out here: every other limiter refuses a request, and this one
// refuses nothing. The refusal happens either way — what it bounds is the
// row. `destination.blocked` is the only audited action recording something
// that did not happen, so it is the only one with no successful state change
// bounding how often a caller can provoke it.
//
// Keyed per actor and per reason code, never per actor alone, because the
// attacker picks the noise: a per-actor budget would let a flood of one
// refusal bury a different one. Shared through Redis like Login, so the
// bound means one thing on a four-replica instance; a Redis that does not
// answer falls back to local buckets, which errs toward writing more rows
// rather than fewer.
BlockedAudit *ratelimit.Limiter
// Upload guards the endpoints that accept a file (M50.5).
//
// **The API limit is not this limit, and the difference is what a request
// costs rather than how many there are.** Everything else under `/api/v1` is
// a JSON body this product caps at 256 KiB and decodes with the standard
// library's parser; an upload is up to `qr.MaxLogoUploadBytes` of somebody
// else's bytes handed to an image decoder. `API_RATE_PER_MIN`'s 600 was
// chosen about the first kind, and inheriting it for the second would be a
// number nobody set for what it would then bound.
//
// Shared through Redis like Login, because a per-replica budget on a
// four-replica instance is four times the limit an operator configured, and
// bandwidth is the resource being protected. A Redis that does not answer
// falls back to this instance's own buckets, which errs toward refusing less
// rather than refusing a legitimate upload.
Upload *ratelimit.Limiter
}
Limiters are the request limits the server enforces. A nil member means that limit is off, which is the whole reason ratelimit.New returns nil for a rate of zero: there is no second "enabled" flag to disagree with the number.
func NewLimiters ¶
NewLimiters builds the limits from configuration.
One construction site for every limit, so the composition root can hand the same values to the router, the redirect handler, the link service and the metrics collector without any of them re-deriving a limit from config. Deliberately not "all three", or all four: a count here is a fact nothing keeps true, which is the shape F69 was. The Redis client may be nil — cache disabled, or Redis unreachable at boot — and then every limit is per instance exactly as it was before M24.
func (Limiters) Stats ¶
func (l Limiters) Stats() map[string]observability.LimiterStats
Stats returns the enabled limiters for the metrics collector, keyed by the label they report under.
Disabled limits are omitted rather than passed as nil pointers: a nil pointer in an interface is not a nil interface, so a disabled limiter would otherwise be collected as a working one reporting zeros.
A limiter left out of this map is worse than one reporting zeros: it emits no series at all — no tracked keys, no overflows, no fallbacks — and absence is indistinguishable from health on a dashboard. BlockedAudit was absent here from the milestone that added it, so the audit-flood it exists to bound was invisible to the runbook's overflow alert; the Stats test now holds this map to the struct's own field count so the next limiter cannot vanish the same way. BlockedAudit meters through AllowKey rather than middleware and never refuses a request, so it has no rate_limited_total series — that is structural, and these three bookkeeping series are the only view of it.
type LinkAPI ¶
LinkAPI serves /api/v1/links and /api/v1/tags.
Handlers stay thin on purpose: they parse, call the service, and map the result. Every authorization and validation decision lives in the service, so the dashboard handlers added in M11 get identical behaviour by calling the same methods rather than by remembering to repeat the checks.
func (*LinkAPI) CreateAutomationRule ¶ added in v0.2.0
func (a *LinkAPI) CreateAutomationRule(w http.ResponseWriter, r *http.Request)
func (*LinkAPI) CreateCampaign ¶ added in v0.2.0
func (a *LinkAPI) CreateCampaign(w http.ResponseWriter, r *http.Request)
func (*LinkAPI) CreateDomain ¶ added in v0.2.0
func (a *LinkAPI) CreateDomain(w http.ResponseWriter, r *http.Request)
func (*LinkAPI) CreateFolder ¶ added in v0.2.0
func (a *LinkAPI) CreateFolder(w http.ResponseWriter, r *http.Request)
func (*LinkAPI) CreateQRCode ¶ added in v0.3.0
func (a *LinkAPI) CreateQRCode(w http.ResponseWriter, r *http.Request)
CreateQRCode adds a named code to a link.
The slug is not in the request and cannot be. It is generated, because it is printed: a caller-chosen one would be a name the caller has to keep unique across the link's codes and correct across every copy already in the world, and the failure mode of getting it wrong is a poster attributing to somebody else's campaign.
func (*LinkAPI) CreateRule ¶ added in v0.2.0
func (a *LinkAPI) CreateRule(w http.ResponseWriter, r *http.Request)
func (*LinkAPI) CreateVariant ¶ added in v0.2.0
func (a *LinkAPI) CreateVariant(w http.ResponseWriter, r *http.Request)
func (*LinkAPI) CreateWebhook ¶ added in v0.2.0
func (a *LinkAPI) CreateWebhook(w http.ResponseWriter, r *http.Request)
func (*LinkAPI) DeleteAutomationRule ¶ added in v0.2.0
func (a *LinkAPI) DeleteAutomationRule(w http.ResponseWriter, r *http.Request)
func (*LinkAPI) DeleteCampaign ¶ added in v0.2.0
func (a *LinkAPI) DeleteCampaign(w http.ResponseWriter, r *http.Request)
func (*LinkAPI) DeleteFolder ¶ added in v0.2.0
func (a *LinkAPI) DeleteFolder(w http.ResponseWriter, r *http.Request)
func (*LinkAPI) DeleteQR ¶ added in v0.2.0
func (a *LinkAPI) DeleteQR(w http.ResponseWriter, r *http.Request)
DeleteQR returns the link's default code to the default style. Not "delete the QR code": since D183 the row carries the code's identity — the flag, and the slug printed in its payload — so the style is written back to the product default rather than the row being removed, and any logo on the code stays. Removing a code is DeleteQRCode below, which since the same reopening will remove any of them while a link has more than one.
func (*LinkAPI) DeleteQRCode ¶ added in v0.3.0
func (a *LinkAPI) DeleteQRCode(w http.ResponseWriter, r *http.Request)
DeleteQRCode removes one of a link's codes.
**Every code is reachable here since D183, the default included**, because every stored code now carries a slug. What is refused is removing a link's *last* code, and that refusal is a 422 rather than a 404: the code is there, and what the caller almost certainly wants is `DELETE /links/{id}/qr`, which restores its style.
**204, and the promotion is not in the response.** Removing the code that holds the default flag promotes the oldest one left, which the service returns and this discards: a body on a 204 is not a body anything reads, and a caller that needs to know which code untagged scans now land on re-lists them. The dashboard is the surface that has to *say* so, and it says so from the same return value.
func (*LinkAPI) DeleteQRCodeLogo ¶ added in v0.3.0
func (a *LinkAPI) DeleteQRCodeLogo(w http.ResponseWriter, r *http.Request)
DeleteQRCodeLogo removes the image from one named code, leaving the code.
func (*LinkAPI) DeleteQRLogo ¶ added in v0.3.0
func (a *LinkAPI) DeleteQRLogo(w http.ResponseWriter, r *http.Request)
DeleteQRLogo removes the image from a link's default code, leaving the code.
func (*LinkAPI) DeleteRegisteredDomain ¶ added in v0.2.0
func (a *LinkAPI) DeleteRegisteredDomain(w http.ResponseWriter, r *http.Request)
func (*LinkAPI) DeleteRule ¶ added in v0.2.0
func (a *LinkAPI) DeleteRule(w http.ResponseWriter, r *http.Request)
func (*LinkAPI) DeleteVariant ¶ added in v0.2.0
func (a *LinkAPI) DeleteVariant(w http.ResponseWriter, r *http.Request)
func (*LinkAPI) DeleteWebhook ¶ added in v0.2.0
func (a *LinkAPI) DeleteWebhook(w http.ResponseWriter, r *http.Request)
func (*LinkAPI) GetAutomationRule ¶ added in v0.2.0
func (a *LinkAPI) GetAutomationRule(w http.ResponseWriter, r *http.Request)
func (*LinkAPI) GetCampaign ¶ added in v0.2.0
func (a *LinkAPI) GetCampaign(w http.ResponseWriter, r *http.Request)
func (*LinkAPI) GetDomain ¶
func (a *LinkAPI) GetDomain(w http.ResponseWriter, r *http.Request)
GetDomain reports the link domain's settings.
func (*LinkAPI) GetQR ¶ added in v0.2.0
func (a *LinkAPI) GetQR(w http.ResponseWriter, r *http.Request)
func (*LinkAPI) GetQRCode ¶ added in v0.3.0
func (a *LinkAPI) GetQRCode(w http.ResponseWriter, r *http.Request)
GetQRCode answers with one named code.
func (*LinkAPI) GetQRCodePNG ¶ added in v0.3.0
func (a *LinkAPI) GetQRCodePNG(w http.ResponseWriter, r *http.Request)
GetQRCodePNG serves a named code's picture as a raster image.
func (*LinkAPI) GetQRCodeSVG ¶ added in v0.3.0
func (a *LinkAPI) GetQRCodeSVG(w http.ResponseWriter, r *http.Request)
GetQRCodeSVG serves a named code's picture.
func (*LinkAPI) GetQRPNG ¶ added in v0.3.0
func (a *LinkAPI) GetQRPNG(w http.ResponseWriter, r *http.Request)
GetQRPNG serves the picture as a raster image (M49).
**This is the endpoint D11 said would never exist, and the reversal is in m49.md and in internal/qr's package comment.** D11's premise was that nothing should rasterise on a request; this rasterises only when somebody asks for a file, and internal/qr bounds what that can allocate. A refusal for a code larger than that bound is a 422 — the size is something the reader can change.
func (*LinkAPI) GetQRSVG ¶ added in v0.2.0
func (a *LinkAPI) GetQRSVG(w http.ResponseWriter, r *http.Request)
GetQRSVG serves the picture.
Written by hand rather than through WriteJSON, on the same shape docs.SpecYAML uses: set the content type, set the cache policy, write the bytes.
func (*LinkAPI) GetSplit ¶ added in v0.2.0
func (a *LinkAPI) GetSplit(w http.ResponseWriter, r *http.Request)
func (*LinkAPI) GetWebhook ¶ added in v0.2.0
func (a *LinkAPI) GetWebhook(w http.ResponseWriter, r *http.Request)
func (*LinkAPI) ListAutomationRules ¶ added in v0.2.0
func (a *LinkAPI) ListAutomationRules(w http.ResponseWriter, r *http.Request)
func (*LinkAPI) ListCampaigns ¶ added in v0.2.0
func (a *LinkAPI) ListCampaigns(w http.ResponseWriter, r *http.Request)
func (*LinkAPI) ListDomains ¶ added in v0.2.0
func (a *LinkAPI) ListDomains(w http.ResponseWriter, r *http.Request)
func (*LinkAPI) ListFolders ¶ added in v0.2.0
func (a *LinkAPI) ListFolders(w http.ResponseWriter, r *http.Request)
func (*LinkAPI) ListQRCodes ¶ added in v0.3.0
func (a *LinkAPI) ListQRCodes(w http.ResponseWriter, r *http.Request)
ListQRCodes answers with every code a link carries, alphabetically by name (M50.8). Which one is the default is the `default` field on each entry rather than the first position, which is what it was until that milestone.
func (*LinkAPI) ListRules ¶ added in v0.2.0
func (a *LinkAPI) ListRules(w http.ResponseWriter, r *http.Request)
func (*LinkAPI) ListWebhookDeliveries ¶ added in v0.2.0
func (a *LinkAPI) ListWebhookDeliveries(w http.ResponseWriter, r *http.Request)
ListWebhookDeliveries answers what a webhook's recent attempts did.
The payload is not returned. It is the event body, it is already whatever the receiver was sent, and returning it would make this endpoint a way to read every link change in the workspace through a permission granted for managing integrations.
func (*LinkAPI) ListWebhooks ¶ added in v0.2.0
func (a *LinkAPI) ListWebhooks(w http.ResponseWriter, r *http.Request)
func (*LinkAPI) Me ¶
func (a *LinkAPI) Me(w http.ResponseWriter, r *http.Request)
Me returns the current identity, including its permissions so a client can render only the actions the user can actually perform.
func (*LinkAPI) MoveFolder ¶ added in v0.2.0
func (a *LinkAPI) MoveFolder(w http.ResponseWriter, r *http.Request)
func (*LinkAPI) RotateWebhookSecret ¶ added in v0.2.0
func (a *LinkAPI) RotateWebhookSecret(w http.ResponseWriter, r *http.Request)
RotateWebhookSecret mints a new signing secret and returns it once.
A POST because it changes something, and because the new secret is the response body: a GET that minted a credential would be one a browser could be made to fetch.
func (*LinkAPI) SetDefaultQRCode ¶ added in v0.3.0
func (a *LinkAPI) SetDefaultQRCode(w http.ResponseWriter, r *http.Request)
SetDefaultQRCode makes one code the one untagged scans resolve through (D183).
**PUT on a sub-resource rather than a field on the code, because it is not a property of one code.** Setting it takes the flag off another, so a `PUT /qr/codes/{slug}` carrying `"default": true` would be a request that silently rewrites a resource it does not name — and one carrying `false` would have no meaning at all, since a link always has a default. The sub-resource has one verb for that reason: there is nothing to DELETE.
Answers with the code, so a caller sees the flag it just set on the same object `GET /qr/codes/{slug}` returns.
func (*LinkAPI) SetDomainRootRedirect ¶ added in v0.2.0
func (a *LinkAPI) SetDomainRootRedirect(w http.ResponseWriter, r *http.Request)
SetDomainRootRedirect points a verified hostname's own root somewhere.
func (*LinkAPI) SetQR ¶ added in v0.2.0
func (a *LinkAPI) SetQR(w http.ResponseWriter, r *http.Request)
SetQR stores the style. A PUT rather than a PATCH: the style is replaced whole, and an omitted field means its default rather than "leave it alone" — which is what makes "put it back to plain black on white" expressible as an empty object rather than as five explicit fields.
func (*LinkAPI) SetQRCode ¶ added in v0.3.0
func (a *LinkAPI) SetQRCode(w http.ResponseWriter, r *http.Request)
SetQRCode replaces a named code's label and style.
PUT rather than PATCH, for the reason SetQR is a PUT: an omitted style field means its default, which is what makes "back to plain black on white" an empty object rather than five explicit fields. The label follows the same rule — omitting it clears the name — because a request that replaces a resource whole and quietly preserves one field is the shape nobody can predict.
func (*LinkAPI) SetQRCodeLogo ¶ added in v0.3.0
func (a *LinkAPI) SetQRCodeLogo(w http.ResponseWriter, r *http.Request)
SetQRCodeLogo replaces the image stored against one of a link's named codes.
func (*LinkAPI) SetQRLogo ¶ added in v0.3.0
func (a *LinkAPI) SetQRLogo(w http.ResponseWriter, r *http.Request)
SetQRLogo replaces the image stored against a link's default code.
PUT rather than POST, for the reason SetQR is a PUT: the request replaces the resource whole, and sending it twice leaves the same state as sending it once.
func (*LinkAPI) Sign ¶ added in v0.2.0
func (a *LinkAPI) Sign(w http.ResponseWriter, r *http.Request)
Sign mints a signed URL for a link (M35).
A POST rather than a GET, and the method is the honest one: this call can bring the workspace's signing secret into existence, and it hands back a bearer capability that should not be sitting in anybody's browser history or proxy log as a URL that was fetched.
The format the URL carries is documented in internal/gate/signature.go and in docs/SECURITY.md, so a client can verify one without this endpoint.
func (*LinkAPI) UpdateAutomationRule ¶ added in v0.2.0
func (a *LinkAPI) UpdateAutomationRule(w http.ResponseWriter, r *http.Request)
func (*LinkAPI) UpdateCampaign ¶ added in v0.2.0
func (a *LinkAPI) UpdateCampaign(w http.ResponseWriter, r *http.Request)
func (*LinkAPI) UpdateDomain ¶
func (a *LinkAPI) UpdateDomain(w http.ResponseWriter, r *http.Request)
UpdateDomain changes the link domain's settings.
PATCH of whichever fields are present rather than PUT of the whole object. Every field is a pointer for the same reason: an absent key means "change nothing", and treating it as a value would let a client clear the root redirect or hand every link's bot decision back to itself by sending `{}`.
The two settings go through separate service calls because they are guarded by the same permission and by nothing else in common — the root redirect is refused outright on a single-host deployment, bot blocking never is. Applying them in one call would mean failing a valid bot change because of the deployment shape.
A body carrying both therefore applies **in order, stopping at the first refusal**, and the order is root redirect then bot blocking. That is stated rather than hidden because it is observable: a request whose URL is rejected changes nothing, while one whose URL is accepted and whose bot pair is not leaves the URL changed. The alternative is a transaction across two service methods with different guards, for a combination neither dashboard form ever sends — both post one setting at a time.
func (*LinkAPI) UpdateFolder ¶ added in v0.2.0
func (a *LinkAPI) UpdateFolder(w http.ResponseWriter, r *http.Request)
func (*LinkAPI) UpdateRegisteredDomain ¶ added in v0.2.0
func (a *LinkAPI) UpdateRegisteredDomain(w http.ResponseWriter, r *http.Request)
func (*LinkAPI) UpdateRule ¶ added in v0.2.0
func (a *LinkAPI) UpdateRule(w http.ResponseWriter, r *http.Request)
func (*LinkAPI) UpdateVariant ¶ added in v0.2.0
func (a *LinkAPI) UpdateVariant(w http.ResponseWriter, r *http.Request)
func (*LinkAPI) UpdateWebhook ¶ added in v0.2.0
func (a *LinkAPI) UpdateWebhook(w http.ResponseWriter, r *http.Request)
func (*LinkAPI) VerifyDomain ¶ added in v0.2.0
func (a *LinkAPI) VerifyDomain(w http.ResponseWriter, r *http.Request)
VerifyDomain runs the DNS challenge now.
A POST because it changes something: a successful check starts serving an alias namespace on a public hostname, which is the most consequential thing this collection does. It is also why the response is the domain rather than a bare status — the caller needs to see `verified` become true, and to see the challenge block when it does not.
type MFAAPI ¶ added in v0.3.0
type MFAAPI struct {
MFA *auth.MFAService
Config config.Config
}
MFAAPI serves the second factor's JSON surface (M53).
Every control the account page draws has an endpoint here, because "every UI feature has API support" is an inherited rule and not a preference. Both surfaces call the same auth.MFAService methods; nothing in either of them decides anything.
**The challenge endpoint is the interesting one.** POST /auth/login answers 401 with a `mfa-required` problem and a pending token when the account has a second factor, and POST /auth/mfa/challenge is where that token is spent. A client that has never heard of a second factor therefore fails closed — it gets no session cookie and an error it does not recognise — rather than believing it signed in.
func (*MFAAPI) Challenge ¶ added in v0.3.0
func (a *MFAAPI) Challenge(w http.ResponseWriter, r *http.Request)
Challenge completes a sign-in that stopped at the second factor.
Unauthenticated, necessarily — there is no session yet, which is the whole point — and registered under the same login limiter as POST /auth/login, so guessing six digits and guessing a password draw on one budget rather than two.
The response is POST /auth/login's, byte for byte, because a client that completed a second factor has finished exactly the same operation and should not have to parse two shapes of "you are signed in".
func (*MFAAPI) Confirm ¶ added in v0.3.0
func (a *MFAAPI) Confirm(w http.ResponseWriter, r *http.Request)
Confirm turns an offered secret into the account's second factor.
The recovery codes come back on this response and on no other, which is what "shown once" means when the surface is JSON: nothing stores them in a readable form, so a client that discards the body has the regenerate endpoint and nothing else.
func (*MFAAPI) Disable ¶ added in v0.3.0
func (a *MFAAPI) Disable(w http.ResponseWriter, r *http.Request)
Disable takes the second factor away.
The API-key refusal happens in the service, through requireSessionActor, and is not duplicated here — unlike ChangePassword's, which predates the service having its own. What reaches this handler is the error, and WriteError already maps it.
func (*MFAAPI) Enrol ¶ added in v0.3.0
func (a *MFAAPI) Enrol(w http.ResponseWriter, r *http.Request)
Enrol offers a secret and the URI that carries it.
**The QR code is rendered here and not by the client**, through internal/qr, which m53.md calls the milestone's one deliberate reuse across work areas: the generator built for links serves the second factor for free, as a new call site rather than a new capability. The SVG comes back as a string in the body so a client has the same picture the dashboard draws without a second request and without knowing how to encode one.
func (*MFAAPI) RegenerateRecoveryCodes ¶ added in v0.3.0
func (a *MFAAPI) RegenerateRecoveryCodes(w http.ResponseWriter, r *http.Request)
RegenerateRecoveryCodes voids the previous set and issues a new one.
type NotificationAPI ¶ added in v0.2.0
NotificationAPI exposes the caller's own inbox.
There is no endpoint that creates a notification. Notifications are a consequence of something the system observed, and an API that could post one would make the inbox a thing callers assert into rather than a record of what happened.
func (*NotificationAPI) List ¶ added in v0.2.0
func (a *NotificationAPI) List(w http.ResponseWriter, r *http.Request)
func (*NotificationAPI) MarkUnread ¶ added in v0.3.0
func (a *NotificationAPI) MarkUnread(w http.ResponseWriter, r *http.Request)
MarkUnread puts one notification back in the unread list (M48).
The API counterpart of the dashboard's undo, so the surfaces stay level: the inherited rule is that every UI feature has API support, and this is the one operation M48 adds that changes state. The click-through itself is navigation and has no API form — a JSON client already receives `kind` and `data` and decides its own destinations.
Idempotent and probe-resistant on the same terms as Read: an id that is not the caller's own changes no rows and answers 204.
func (*NotificationAPI) Read ¶ added in v0.2.0
func (a *NotificationAPI) Read(w http.ResponseWriter, r *http.Request)
Read marks one notification read. Idempotent, and someone else's id is indistinguishable from one that does not exist.
func (*NotificationAPI) ReadAll ¶ added in v0.2.0
func (a *NotificationAPI) ReadAll(w http.ResponseWriter, r *http.Request)
ReadAll empties the badge and reports how many it cleared.
func (*NotificationAPI) Unread ¶ added in v0.2.0
func (a *NotificationAPI) Unread(w http.ResponseWriter, r *http.Request)
Unread answers the badge count on its own, so a client polling it does not pay for a page of rows it will not render.
type Problem ¶
type Problem struct {
Type string `json:"type"`
Title string `json:"title"`
Status int `json:"status"`
Detail string `json:"detail,omitempty"`
Instance string `json:"instance,omitempty"`
Errors []domain.FieldError `json:"errors,omitempty"`
// Extra carries extension members, which RFC 7807 §3.2 provides for and
// which this type had no way to emit until M53.
//
// **One caller, and it is the one that needs it.** A sign-in that stops at
// the second factor answers 401 with the pending token in the body: the
// refusal and the way forward are one document, because a client that got a
// 401 and had to make a second request to find out what to do would be a
// client most implementations never write. Anything that fits in `detail` or
// in `errors` belongs there instead — this is not a general escape hatch, and
// a second use of it is worth arguing about rather than copying.
//
// Keys that collide with a field above are dropped, so an extension cannot
// rewrite the status or the type of the document carrying it.
Extra map[string]any `json:"-"`
}
Problem is an RFC 9457 problem detail.
One representation for every API error, so a client can branch on `type` rather than on prose, and field-level failures arrive in a shape a form can render directly.
func (Problem) MarshalJSON ¶ added in v0.3.0
MarshalJSON emits the document with its extension members merged in.
The alias type is what stops this recursing: marshalling `Problem` from inside `Problem.MarshalJSON` would call this method again.
type RecoveryAPI ¶ added in v0.3.0
RecoveryAPI is account recovery, as a program sees it (M51).
It exists because *every UI feature has API support* is an inherited rule and this is not an exception to it: the browser forms in web_recovery.go post at the same two service calls, so a client can ask for a link and spend one exactly as a person can.
Both endpoints are unauthenticated, necessarily — the caller has lost the only credential they had — and both are registered under the same login limiter as POST /auth/login, so an attacker cannot double their budget by alternating between recovery and sign-in.
**There is no GET half.** The browser's two GETs draw forms; a JSON client has no form to draw, and a GET that answered anything about a token would be the enumeration oracle both surfaces are built to avoid.
func (*RecoveryAPI) Forgot ¶ added in v0.3.0
func (a *RecoveryAPI) Forgot(w http.ResponseWriter, r *http.Request)
Forgot asks for a reset link.
**202 for every address**, with the same body and the same argon2 cost, whether or not the address has an account and whether or not that account can be recovered. The answer goes to the address by mail, which is the stance registration already takes (F13): the channel that proves an address exists is the mailbox, and a status code reaches whoever typed the address into the form.
503 is the one honest exception, and it is about the instance rather than the address: with no `SMTP_HOST` there is no mechanism at all, and answering 202 to a caller whose instance can send nothing would be the silent success this milestone exists to refuse.
func (*RecoveryAPI) Reset ¶ added in v0.3.0
func (a *RecoveryAPI) Reset(w http.ResponseWriter, r *http.Request)
Reset spends a token and writes the new password.
204 rather than a session. The reset revokes every session on the account, so answering with one would replace the credential being displaced in the same breath; the caller signs in with the password they just set, which is also the proof they know it.
The token travels in the body and not in the path, unlike the browser's route. A URL is written to the access log, the referrer header and the client's own history by default, and this one sets a password — the browser has no choice because a link in a mail is a URL, and a JSON client has no such excuse. It is the shape POST /invitations/redeem already uses for the same kind of secret.
type RedirectHandler ¶
type RedirectHandler struct {
Resolver *redirect.Resolver
// DomainID is resolved once at boot. Looking it up per request would add a
// query to the path this whole design exists to keep short.
DomainID uuid.UUID
Status int
Logger *slog.Logger
// LogSample logs one in N successful redirects; 0 disables. Logging every
// redirect at 2,000 rps produces more bytes than the redirects themselves.
LogSample int64
// Recorder receives click events. Nil until M8.
Recorder ClickRecorder
// Metrics is optional; a nil value makes every observation a no-op. This
// is the SLO's own measurement point, so it lives here rather than in
// middleware: the outer view includes the router's dispatch, and the
// number the target names is the time to resolve and answer.
Metrics *observability.Metrics
// Geo resolves a country, region or city for routing rules (M34).
//
// Optional, and nil on every instance that has not supplied a MaxMind
// database — which is the default. A geographic rule on such an instance
// never matches, which is the same answer an address in nobody's range gets,
// and is documented rather than silently treated as "matches everybody".
//
// Never consulted for a link with no rules, and never for a rule that does
// not ask: see redirect_rules.go.
Geo GeoResolver
// Returning is the within-day returning-visitor set (M34). Optional; nil
// means every visitor reads as new.
Returning *analytics.ReturningSet
// NotFoundLimiter throttles addresses that keep asking for aliases which do
// not exist. Optional; nil disables it and costs nothing.
//
// It lives here rather than in middleware because only a miss may be charged.
// A hit must never spend a token — otherwise a popular link would throttle
// its own audience — and middleware cannot tell a hit from a miss without
// intercepting the response.
NotFoundLimiter *ratelimit.Limiter
// Gates answers the questions a gated link asks Postgres (M35): does this
// password match, does this signature verify, is there budget left.
//
// Nil means the gates cannot be enforced, and a *gated* link then answers 503
// rather than redirecting — see passGates. An ungated link never consults it,
// which is why nil is a valid configuration for a process that only ever
// serves ordinary links.
Gates Gatekeeper
// PasswordLimiter throttles guesses at a link password, per address and per
// alias (D54). Optional; nil disables it.
//
// It is the shared limiter M24 built rather than a mechanism of its own, so
// a guess costs the same Redis token bucket a login attempt does — and falls
// back to this instance's own buckets when Redis is unavailable, which makes
// the protection best-effort rather than a guarantee.
PasswordLimiter *ratelimit.Limiter
// contains filtered or unexported fields
}
RedirectHandler serves GET|HEAD /{alias}.
This is the hot path and the whole reason for the router's shape. It runs with no session lookup, no CSRF check and no template rendering, because each of those is a cost the 20ms budget cannot absorb — the session check alone would be a database round trip on every visit.
func (*RedirectHandler) Location ¶
func (h *RedirectHandler) Location(w http.ResponseWriter, target string, status int)
Location writes the redirect response.
func (*RedirectHandler) ServeHTTP ¶
func (h *RedirectHandler) ServeHTTP(w http.ResponseWriter, r *http.Request)
type Report ¶
type Report struct {
Status string `json:"status"`
Version string `json:"version"`
Dependencies map[string]string `json:"dependencies,omitempty"`
Errors map[string]string `json:"errors,omitempty"`
}
Report is the readiness response body.
type RootRedirect ¶
type RootRedirect struct {
// Load reads the current destination. Empty means "not configured", which
// is answered 404.
Load func(context.Context) (string, error)
// TTL bounds staleness if an invalidation is ever missed. Zero means one
// minute.
TTL time.Duration
// Status is the redirect code, defaulting to 302. It follows the instance's
// configured default rather than being pinned here: an operator who chose
// 301 for links made that decision once already. The 302 default is the
// product's usual reasoning — a 301 cached in browsers and intermediaries
// cannot be recalled — and it applies most strongly to this destination,
// which is the one most likely to be repointed later.
Status int
// LoadTimeout bounds one refill; see DefaultRootLoadTimeout.
LoadTimeout time.Duration
// contains filtered or unexported fields
}
RootRedirect serves the root of the link domain.
It lives on the redirect tree, under the same latency budget as an alias, and the bare domain is a URL crawlers and scanners ask for constantly — so the value is cached rather than read per request. A database round trip here would put one on the most-probed path in the product for a value that changes approximately never.
Refreshed on a TTL and invalidated on write, the same shape as a link snapshot. The TTL alone would be enough for correctness and would leave an operator reloading the page they just configured and seeing the old answer.
func (*RootRedirect) InvalidateRoot ¶
func (h *RootRedirect) InvalidateRoot()
InvalidateRoot drops the cached value.
func (*RootRedirect) ServeHTTP ¶
func (h *RootRedirect) ServeHTTP(w http.ResponseWriter, r *http.Request)
type StatsAPI ¶
StatsAPI serves analytics reads.
func (*StatsAPI) LinkClicks ¶
func (a *StatsAPI) LinkClicks(w http.ResponseWriter, r *http.Request)
type TLSAsk ¶ added in v0.2.0
type TLSAsk struct {
Hosts *redirect.HostCache
// Activate records that the ask was answered for this domain, so ssl_status
// stops saying `pending` for a certificate Caddy has now been told to get.
// Nil records nothing, which leaves the column at `pending` and costs
// nothing else.
Activate func(ctx context.Context, d redirect.VerifiedDomain)
// ActivateTimeout bounds that write. Zero means DefaultActivateTimeout.
ActivateTimeout time.Duration
}
TLSAsk answers Caddy's on-demand TLS `ask` (decision D3).
**The app never speaks ACME.** Certificates are the operator's, obtained by Caddy, and this endpoint is the whole of this program's part in it: Caddy asks "should I get a certificate for this name?" and the honest answer is "yes if and only if a workspace has proved it controls that name". Answering anything wider would make this instance an unauthenticated certificate-issuance trigger for any hostname on the internet, which is the abuse `ask` exists to prevent.
It answers **only for verified custom domains**. The instance's own app and link hosts are configured statically in the Caddyfile — they are the operator's names, known before any request arrives — so answering for them here would be widening the endpoint for no gain.
Unauthenticated by necessity: it is consulted during a TLS handshake, before any application request exists. That is affordable because it reads the same in-process map the router does, discloses only whether a name is already being served publicly, and performs at most one write per verification.
type TeamAPI ¶ added in v0.2.0
TeamAPI is member management, workspace lifecycle and organization creation, as a program sees it.
The dashboard's forms post at the handlers in web_team.go and both reach the same service calls, so a client can re-role, remove, grant, create, rename and delete exactly as a person can.
The permissions are the ones the service enforces, and nothing here re-checks them: members.read to look, members.write to change a membership, workspace.write to change a workspace, orgs.create to make an organization. All four are delegable to an API key — none of them discloses an identity tied to network data, and none lets a key widen its own reach, because a key's permissions are its scopes intersected with its owner's role on every request (D18).
org.delete, added by M28.5, is the exception and was already decided: it has sat in auth.NonDelegableScopes since Phase 1, on D18's **irreversible** limb — an action with no undo belongs behind an interactive sign-in rather than behind a token in a CI variable. M28.5 gives it its first operation and changes nothing about that; the map is the only mechanism, and no handler here asks which credential it was called with.
func (*TeamAPI) ChangeMemberRole ¶ added in v0.2.0
func (a *TeamAPI) ChangeMemberRole(w http.ResponseWriter, r *http.Request)
ChangeMemberRole re-roles one membership.
403 when the membership is not below the caller's own rank — including their own, which is what "an admin cannot demote themselves" looks like from here — and 409 when it would leave the organization without an owner.
func (*TeamAPI) CreateOrganization ¶ added in v0.2.0
func (a *TeamAPI) CreateOrganization(w http.ResponseWriter, r *http.Request)
CreateOrganization provisions an organization, its first workspace and an owner membership for the caller, in one transaction.
Requires `orgs.create`, which on a default instance is held by the account from the setup form and by nobody else (D16).
func (*TeamAPI) CreateWorkspace ¶ added in v0.2.0
func (a *TeamAPI) CreateWorkspace(w http.ResponseWriter, r *http.Request)
CreateWorkspace adds a workspace to the caller's organization.
func (*TeamAPI) DeleteOrganization ¶ added in v0.2.0
func (a *TeamAPI) DeleteOrganization(w http.ResponseWriter, r *http.Request)
DeleteOrganization tears down the organization the caller is acting in.
The id names the organization being deleted and must be the caller's current one; anything else is 404, so an id cannot be probed and a mistyped one deletes nothing. 409 while it still holds any link (D37) or while it is the instance's only organization.
Requires `org.delete`, held by the `owner` role alone and not delegable to an API key — so this endpoint answers a session and nothing else, without branching on the credential (D18, auth.NonDelegableScopes).
func (*TeamAPI) DeleteWorkspace ¶ added in v0.2.0
func (a *TeamAPI) DeleteWorkspace(w http.ResponseWriter, r *http.Request)
DeleteWorkspace removes a workspace, and refuses with 409 while it holds any link at all or while it is the organization's last one (D32).
func (*TeamAPI) GrantMember ¶ added in v0.2.0
func (a *TeamAPI) GrantMember(w http.ResponseWriter, r *http.Request)
GrantMember gives an existing member a role in one workspace.
It **adds** access and never narrows it (D31): permissions resolve as the union of every matching membership, so an organization-wide editor granted admin in one workspace is an admin there and an editor everywhere else. There is no operation that restricts somebody to a workspace, and this is not it.
func (*TeamAPI) ListMembers ¶ added in v0.2.0
func (a *TeamAPI) ListMembers(w http.ResponseWriter, r *http.Request)
ListMembers returns every membership in the caller's organization.
Not paginated, for the reason the invitation list is not: an organization's membership is a handful of rows by construction, and a cursor here would be machinery for a page that cannot fill.
func (*TeamAPI) RemoveMember ¶ added in v0.2.0
func (a *TeamAPI) RemoveMember(w http.ResponseWriter, r *http.Request)
RemoveMember ends one membership.
The membership, not the account: everything else that account holds survives, which is what makes this reversible by inviting them back.
func (*TeamAPI) RenameWorkspace ¶ added in v0.2.0
func (a *TeamAPI) RenameWorkspace(w http.ResponseWriter, r *http.Request)
RenameWorkspace changes a workspace's name, and its slug with it.
type Web ¶
type Web struct {
UI *ui.Renderer
Config config.Config
Auth *auth.Service
Keys *auth.APIKeyService
Links *link.Service
Stats *analytics.Reader
Notify *notify.Service
// Invites serves both halves of the invitation surface: the administrator's
// page and the public redemption form.
Invites *invite.Service
// Team serves member management, workspace lifecycle and the organization
// lifecycle. Nil leaves its three pages unregistered — including the one an
// account belonging to nothing is held on, which is why an instance wired
// without it can never reach that state either.
Team *team.Service
// Signup owns whether this instance accepts new accounts, and the public
// form. Nil leaves the signup and verification pages unregistered, and every
// registration refused.
Signup *signup.Service
// Recovery repairs a forgotten password (M51). Nil leaves both public pages
// unregistered and the "Forgot your password?" link undrawn, which is the
// state every instance was in before this existed — a lockout with no route
// back but the operator's database client (F141).
Recovery *recovery.Service
// Accounts ends an account's life (M52). Nil leaves the delete section off
// the account page and its route unregistered, which is the state every
// instance was in before this existed — no account deletion of any kind,
// for anybody (F44).
Accounts *account.Service
// MFA owns the second factor (M53). Nil leaves the enrolment page, the code
// prompt and the account page's section unregistered, which is the state
// every instance was in before this existed. Non-nil with no MFA_SECRET_KEY
// is a different state and the page says so: enrolled accounts still stop at
// the prompt, and their route on is a recovery code.
MFA *auth.MFAService
// Disputes serves the review queue and the appeal a refused creator files.
// Nil leaves the page unregistered and takes the "ask for a review" button
// off the link form, because a refusal must not offer a door that is not
// there.
Disputes *dispute.Service
// Instance backs the reviewer roster on the dispute queue (D98). Nil leaves
// the section undrawn and its two routes unregistered, which is the state a
// deployment without the queue is already in.
Instance *instance.Service
}
Web serves the HTML dashboard.
Every handler here is a thin skin over the same service calls the JSON API makes. That is the mechanism behind the "every UI feature has API support" success criterion: the two surfaces cannot diverge because there is nothing in either of them to diverge — validation, authorization and behaviour all live one layer down.
func (*Web) AccountDelete ¶ added in v0.3.0
func (h *Web) AccountDelete(w http.ResponseWriter, r *http.Request)
AccountDelete handles the dashboard's account-deletion form (M52).
The same service call the JSON endpoint makes, with the browser's answer to each refusal instead of a problem document. Nothing is decided here: the API key check below is the one duplicate, and it is duplicated for the reason PasswordChange duplicates it — the person gets a sentence rather than a permission slug they never asked to hold.
**Where it lands is the interesting part.** On success there is no signed-in surface left to render — the session row is gone with the account — so the cookie is expired here and the browser goes to /login, which says what happened and names the erasure lag. Redirecting to /account would have meant the auth middleware answering the redirect with another one.
func (*Web) AccountPage ¶
func (h *Web) AccountPage(w http.ResponseWriter, r *http.Request)
func (*Web) AutomationCreate ¶ added in v0.2.0
func (h *Web) AutomationCreate(w http.ResponseWriter, r *http.Request)
func (*Web) AutomationDelete ¶ added in v0.2.0
func (h *Web) AutomationDelete(w http.ResponseWriter, r *http.Request)
func (*Web) AutomationPage ¶ added in v0.2.0
func (h *Web) AutomationPage(w http.ResponseWriter, r *http.Request)
func (*Web) AutomationToggle ¶ added in v0.2.0
func (h *Web) AutomationToggle(w http.ResponseWriter, r *http.Request)
AutomationToggle is the pause switch, on its own form.
Its own action rather than a field on the edit form, for the reason the webhook, rule and split toggles have one: switching a misbehaving instruction off is what somebody reaches for first, and it must not require opening an editor.
Switching one back **on** re-arms it — the watermark moves to now — so a rule paused for a month does not fire for a month of backlog the instant somebody unpauses it. That happens inside UpdateAutomationRule's statement; the notice below is where a person is told.
func (*Web) AutomationUpdate ¶ added in v0.2.0
func (h *Web) AutomationUpdate(w http.ResponseWriter, r *http.Request)
func (*Web) BotBlockingUpdate ¶ added in v0.2.0
func (h *Web) BotBlockingUpdate(w http.ResponseWriter, r *http.Request)
BotBlockingUpdate handles the domain bot-blocking form.
Both checkboxes are read from one submission, because the two settings are written together — enforcing without blocking is refused, and a form that sent them separately would produce that state in between.
func (*Web) CampaignCreate ¶ added in v0.2.0
func (h *Web) CampaignCreate(w http.ResponseWriter, r *http.Request)
func (*Web) CampaignDelete ¶ added in v0.2.0
func (h *Web) CampaignDelete(w http.ResponseWriter, r *http.Request)
func (*Web) CampaignUpdate ¶ added in v0.2.0
func (h *Web) CampaignUpdate(w http.ResponseWriter, r *http.Request)
func (*Web) CampaignsPage ¶ added in v0.2.0
func (h *Web) CampaignsPage(w http.ResponseWriter, r *http.Request)
func (*Web) DisputeAllow ¶ added in v0.2.0
func (h *Web) DisputeAllow(w http.ResponseWriter, r *http.Request)
DisputeAllow lifts the entry that refused the destination.
func (*Web) DisputeFile ¶ added in v0.2.0
func (h *Web) DisputeFile(w http.ResponseWriter, r *http.Request)
DisputeFile is how a person who was refused asks for a review.
It posts the URL they typed, not a reference to the refusal, because the refusal is not a row anybody holds an id for — the service re-judges the URL and only a low-confidence verdict produces a dispute. So a form field cannot claim an appealable tier, whatever it says.
It answers by returning to the links page carrying the outcome, rather than by rendering a page of its own. There is nothing to show: the interesting state is now in somebody else's queue.
func (*Web) DisputeReviewerGrant ¶ added in v0.2.0
func (h *Web) DisputeReviewerGrant(w http.ResponseWriter, r *http.Request)
DisputeReviewerGrant appoints somebody to this instance's review queue.
It carries an address rather than an id, because the principal appointing somebody knows who they are and not what their uuid is. An address with no account is a sentence on the page rather than an error page: the form is on the queue, and mistyping an address is not a reason to take the queue away.
func (*Web) DisputeReviewerRevoke ¶ added in v0.2.0
func (h *Web) DisputeReviewerRevoke(w http.ResponseWriter, r *http.Request)
DisputeReviewerRevoke withdraws it.
It cannot reach the principal's own instance.admin — the service revokes only what auth.InstanceGrantable holds — so the instance cannot be left with nobody able to appoint anybody. The page also declines to draw the control against the signed-in account, which is an affordance rather than the enforcement.
func (*Web) DisputeReviewersPage ¶ added in v0.3.0
func (h *Web) DisputeReviewersPage(w http.ResponseWriter, r *http.Request)
DisputeReviewersPage serves the reviewer roster at its own URL (M48).
The panel's route, and the second caller of the pattern the QR area introduced: what is on the queue is a summary, and changing the list is here.
**The gate is unchanged and it is checked here rather than left to the POST.** `Instance.Reviewers` refuses anybody without instance.admin, which is exactly the guard the section on the queue is drawn under (D98) — a page that rendered the form for somebody who cannot use it would be offering a control that answers 403.
func (*Web) DisputeUphold ¶ added in v0.2.0
func (h *Web) DisputeUphold(w http.ResponseWriter, r *http.Request)
DisputeUphold leaves the refusal standing.
func (*Web) DisputesPage ¶ added in v0.2.0
func (h *Web) DisputesPage(w http.ResponseWriter, r *http.Request)
DisputesPage is the review queue.
**It renders a URL a stranger chose, to the person who administers the instance.** Two rules carry that, and both are asserted against the rendered HTML by TestTheQueueNeverRendersADisputedDestinationAsALink: the destination appears defanged, and it never appears inside an anchor, a form action, an image source or anything else a browser would follow.
The server also never fetches it. There is no preview, no screenshot and no liveness check anywhere behind this page.
func (*Web) DomainCreate ¶ added in v0.2.0
func (h *Web) DomainCreate(w http.ResponseWriter, r *http.Request)
func (*Web) DomainDelete ¶ added in v0.2.0
func (h *Web) DomainDelete(w http.ResponseWriter, r *http.Request)
func (*Web) DomainRename ¶ added in v0.2.0
func (h *Web) DomainRename(w http.ResponseWriter, r *http.Request)
func (*Web) DomainRootRedirect ¶ added in v0.2.0
func (h *Web) DomainRootRedirect(w http.ResponseWriter, r *http.Request)
DomainRootRedirect points a verified hostname's own root somewhere, or clears it when the box is empty.
func (*Web) DomainUpdate ¶
func (h *Web) DomainUpdate(w http.ResponseWriter, r *http.Request)
DomainUpdate handles the link-domain form.
func (*Web) DomainVerify ¶ added in v0.2.0
func (h *Web) DomainVerify(w http.ResponseWriter, r *http.Request)
DomainVerify runs the DNS challenge now (M40).
A refusal comes back as a form error on the row, which is the right shape for it: "no TXT record was found at _linkctrl-challenge.go.example.com" is something the reader fixes in their DNS provider and then presses this again.
func (*Web) DomainsPage ¶ added in v0.2.0
func (h *Web) DomainsPage(w http.ResponseWriter, r *http.Request)
func (*Web) FeedsPage ¶ added in v0.2.0
func (h *Web) FeedsPage(w http.ResponseWriter, r *http.Request)
FeedsPage discloses what this instance does with destinations.
**It is read-only, it has no controls, and it accepts no POST.** That is decision D40 and it is asserted by TestTheDisclosurePageAcceptsNoWrite rather than left to whoever edits the template next. The reason is worth carrying here, because the next person to want a row on this page will not read the decision log first:
D38 removed the ability to change instance-wide settings from the dashboard. At the time this product had no instance-level principal — under open signup every stranger who registers owns an organization — so there was nobody the permission system could name who may move a setting that affects everyone. Reading is not changing, and being told what an instance does with your destinations is not a privilege that needs a principal.
**D98 introduced a principal, and it does not reach this page** (M45). Its scopes are enumerated rather than implied — the dispute queue, the blocklist entries those decisions lift, and the instance-wide audit surface — and the feed configuration is deliberately not among them, because a principal that accumulates scopes because it exists is the thing D38 was avoiding. So the risk is unchanged and one degree sharper: it is the toggle somebody adds beside the row next year, now that there is a permission it would look plausible under, at which point D38 has been reversed by nobody in particular. The no-POST test is what makes that an explicit act.
Ungated on purpose. Every signed-in user's destinations are what get sent, so every signed-in user may read this — a disclosure only owners can see is a disclosure to the people who already configured it.
**Ungated includes the webhook half**, which is the one thing about this page that is not a copy of how it shipped. Reading the registry — who a workspace posts to — needs `webhooks.read`; being told that *something* receives the destinations you type needs nothing, because it is a fact about your own data rather than about the workspace's configuration. link.WebhookDisclosure is what enforces that distinction: it carries a count and no URL.
func (*Web) FolderCreate ¶ added in v0.2.0
func (h *Web) FolderCreate(w http.ResponseWriter, r *http.Request)
func (*Web) FolderDelete ¶ added in v0.2.0
func (h *Web) FolderDelete(w http.ResponseWriter, r *http.Request)
func (*Web) FolderMove ¶ added in v0.2.0
func (h *Web) FolderMove(w http.ResponseWriter, r *http.Request)
FolderMove is the second click of click-to-move. An empty `parent_id` is the top level, which is the one destination that is not a row on the page.
func (*Web) FolderRename ¶ added in v0.2.0
func (h *Web) FolderRename(w http.ResponseWriter, r *http.Request)
func (*Web) FoldersPage ¶ added in v0.2.0
func (h *Web) FoldersPage(w http.ResponseWriter, r *http.Request)
func (*Web) ForgotPage ¶ added in v0.3.0
func (h *Web) ForgotPage(w http.ResponseWriter, r *http.Request)
ForgotPage renders the form that asks for a reset link.
The refusal for a mail-free instance is rendered here, on the GET, rather than discovered at submit time — the same shape SignupPage uses, and for the same reason: filling in an address and then being told the instance could never have sent anything is a worse answer than being told first.
Unlike signup's refusal, this one names the reason. There is nothing to protect: an instance with no relay has already said so at the sign-up form, and "ask the operator" is useless advice without the sentence explaining why.
func (*Web) ForgotSubmit ¶ added in v0.3.0
func (h *Web) ForgotSubmit(w http.ResponseWriter, r *http.Request)
ForgotSubmit queues a reset link, or the message that says none was created.
One response for every outcome. A found account, an unknown address and an account this mechanism refuses all render the same block with the same words, and the service spends the same argon2 cost on each — so neither the page nor a stopwatch answers whether an address is registered.
func (*Web) InviteAccept ¶ added in v0.2.0
func (h *Web) InviteAccept(w http.ResponseWriter, r *http.Request)
InviteAccept redeems the invitation and signs the person in.
Signing in here is the difference from the JSON endpoint, and it is the same trade the first-run setup form makes: the password was in hand exactly once, and bouncing somebody to a login form to retype what they just typed helps nobody.
func (*Web) InviteCreate ¶ added in v0.2.0
func (h *Web) InviteCreate(w http.ResponseWriter, r *http.Request)
InviteCreate issues an invitation and renders the page with the link on it.
func (*Web) InvitePage ¶ added in v0.2.0
func (h *Web) InvitePage(w http.ResponseWriter, r *http.Request)
InvitePage renders the invitation somebody was sent.
It shows which organization and which role, and deliberately not the address the invitation was issued to. Printing that would hand whoever picked the link up the one thing they need to redeem it, which is exactly what binding the invitation to an address is for (D27).
func (*Web) InviteRevoke ¶ added in v0.2.0
func (h *Web) InviteRevoke(w http.ResponseWriter, r *http.Request)
InviteRevoke ends an invitation.
func (*Web) InvitesPage ¶ added in v0.2.0
func (h *Web) InvitesPage(w http.ResponseWriter, r *http.Request)
InvitesPage lists the organization's invitations and offers the form.
func (*Web) KeyCreate ¶
func (h *Web) KeyCreate(w http.ResponseWriter, r *http.Request)
KeyCreate mints a key and renders the page directly — no redirect.
A redirect would drop the token, which exists only in this response; the alternative is stashing it in a flash cookie, which would put a live credential in a Set-Cookie header for nothing. The cost is that refreshing this response re-submits and mints a second key, which is visible in the list and revocable, and the browser warns before doing it.
func (*Web) LinkArchive ¶
func (h *Web) LinkArchive(w http.ResponseWriter, r *http.Request)
func (*Web) LinkCreate ¶
func (h *Web) LinkCreate(w http.ResponseWriter, r *http.Request)
func (*Web) LinkDelete ¶
func (h *Web) LinkDelete(w http.ResponseWriter, r *http.Request)
func (*Web) LinkDetail ¶
func (h *Web) LinkDetail(w http.ResponseWriter, r *http.Request)
func (*Web) LinkQRLogo ¶ added in v0.3.0
func (h *Web) LinkQRLogo(w http.ResponseWriter, r *http.Request)
LinkQRLogo stores an image against the code the panel is open on (M50.5).
**Its own route because a file needs its own body.** Every other write in this panel posts `application/x-www-form-urlencoded` to `POST /links/{id}/qr`, which LinkQRStyle reads with `parseForm`; a file cannot travel in that, and a handler that branched on the content type would be two handlers wearing one name. So the upload posts here and the removal stays there, which is also what keeps the removal available with no file selected.
**`next` and `code` arrive as parts of the multipart body**, because that is the only body this form has. They are the same two hidden inputs every other form in the panel carries and they mean the same things — where the save returns to, matched against the two paths qrReturn builds itself, and which code is being edited, which the service refuses if the link does not have it.
The default code is reachable here exactly as a named one is. Its `code` value is whatever slug the panel is open on — the empty string only on a link whose single code has none — and the service resolves the default from the flag rather than from that emptiness (D183). The owner's ruling of 2026-08-07 is what made the default addressable for a logo at all.
**The form submits itself the moment a file is chosen** (F214c), through an htmx `change` trigger on the form. Nothing of this product's own submits it and nothing new is asked of `script-src 'self'` — which is what that sentence was defending and is still true, though since M50.8 it is no longer true that no script of this product's runs at all: `static/js/qr-size.js` listens on `htmx:beforeRequest` and this post is one of the requests it sees, purely to remember where the reader was standing (D193). It neither triggers this request nor touches its body. Nothing about this handler changes for it: htmx sends the same multipart body, and `seeOther` already answers an htmx request with `HX-Redirect`, which is a full page load rather than a swap. What *did* change is the refusal — see finishQRAction, which cannot render a 422 into an htmx swap because htmx does not swap one.
func (*Web) LinkQRPage ¶ added in v0.3.0
func (h *Web) LinkQRPage(w http.ResponseWriter, r *http.Request)
LinkQRPage serves the QR contents at their own URL.
**The popup this route backed retired at the F212 reopening** (2026-08-11): the QR tab renders the same block in flow now. The route outlived it as what a bookmark and a shared URL reach, and TestEveryPanelIsAlsoACompletePage still holds it to rendering as a complete page.
**The codes list no longer selects through it** (M50.8's third reopening). It did, with the same `?code=` this handler still reads — which meant picking a code on the link page was a load onto this page, which has no link heading row (F244(b)) and starts at the top (F246(d)). The list names the code on the link page now; this route reads the parameter for its own readers, unchanged.
Gated by loading the link, which is `links.read`: the same permission the section on the link page is drawn under. Nothing here widens who may see a code, and the style form inside it is still drawn only for `links.update`.
func (*Web) LinkQRStyle ¶ added in v0.2.0
func (h *Web) LinkQRStyle(w http.ResponseWriter, r *http.Request)
LinkQRStyle stores how this link's code is drawn.
func (*Web) LinkRestore ¶
func (h *Web) LinkRestore(w http.ResponseWriter, r *http.Request)
func (*Web) LinkSign ¶ added in v0.2.0
func (h *Web) LinkSign(w http.ResponseWriter, r *http.Request)
LinkSign mints a signed URL and shows it on the link's own page (M35).
**Deliberately not a POST-redirect-GET**, which is what every other write on this page does. The thing being produced is a bearer capability, and carrying it back through a redirect would put it in the browser's address bar, its history, the proxy log in between and the Referer header of whatever the person clicks next. Rendering it in the response body keeps it in one place the person can copy from and nowhere else.
func (*Web) LinkUpdate ¶
func (h *Web) LinkUpdate(w http.ResponseWriter, r *http.Request)
func (*Web) LoginSubmit ¶
func (h *Web) LoginSubmit(w http.ResponseWriter, r *http.Request)
func (*Web) MFAChallengePage ¶ added in v0.3.0
func (h *Web) MFAChallengePage(w http.ResponseWriter, r *http.Request)
MFAChallengePage draws the code prompt.
Reached only by being redirected here from a completed password post, which carries the token. A GET with no token renders the form empty and the POST refuses it, rather than this page deciding anything — the token is not validated here for the reason ResetPage does not validate its own: doing so would make the page an oracle for which tokens exist.
func (*Web) MFAChallengeSubmit ¶ added in v0.3.0
func (h *Web) MFAChallengeSubmit(w http.ResponseWriter, r *http.Request)
MFAChallengeSubmit completes the sign-in.
Under the same login limiter as POST /login, so guessing six digits and guessing a password draw on one budget. The refusals are deliberately few and indistinguishable from each other for the reason the sign-in form's are (F92).
func (*Web) MFADisable ¶ added in v0.3.0
func (h *Web) MFADisable(w http.ResponseWriter, r *http.Request)
MFADisable takes the second factor away, on the password and a code.
It lands back on /account rather than on this page, because with the factor gone there is nothing here to look at: the offer is what remains, and offering it in the same breath as removing the thing it offers reads as an undo button.
func (*Web) MFAEnrol ¶ added in v0.3.0
func (h *Web) MFAEnrol(w http.ResponseWriter, r *http.Request)
MFAEnrol confirms an enrolment with a code computed from the offered secret.
A wrong code re-renders the *same* secret rather than minting another, so somebody whose phone clock is a few seconds out can try again without rescanning — and so the QR on screen does not change under them mid-enrolment.
func (*Web) MFAPage ¶ added in v0.3.0
func (h *Web) MFAPage(w http.ResponseWriter, r *http.Request)
MFAPage draws the enrolment offer, or the enrolled state.
The secret is minted on the GET, so opening the page twice offers two different secrets and only the one that gets confirmed becomes real. That is a property of nothing being stored: there is no candidate to collide with.
func (*Web) MFARegenerate ¶ added in v0.3.0
func (h *Web) MFARegenerate(w http.ResponseWriter, r *http.Request)
MFARegenerate voids the previous recovery codes and shows a new set.
func (*Web) MemberGrant ¶ added in v0.2.0
func (h *Web) MemberGrant(w http.ResponseWriter, r *http.Request)
MemberGrant gives an existing member a role in one workspace.
func (*Web) MemberRemove ¶ added in v0.2.0
func (h *Web) MemberRemove(w http.ResponseWriter, r *http.Request)
MemberRemove ends one membership.
func (*Web) MemberRole ¶ added in v0.2.0
func (h *Web) MemberRole(w http.ResponseWriter, r *http.Request)
MemberRole re-roles one membership.
func (*Web) MembersPage ¶ added in v0.2.0
func (h *Web) MembersPage(w http.ResponseWriter, r *http.Request)
MembersPage lists the organization's memberships.
func (*Web) NotificationOpen ¶ added in v0.3.0
func (h *Web) NotificationOpen(w http.ResponseWriter, r *http.Request)
NotificationOpen goes to what a notification is about, and marks it read (M48).
**A POST, not a link.** Opening one changes state, and a state change behind a GET is one a prefetch, a link checker or an <img> on somebody else's page can fire — the notification would be read before anybody saw it. The surfaces render the title as a submit button styled as a heading, which is the same trade the sign-out control in the header already makes.
**The destination is computed from the row, never from the request.** The id in the path is the only thing the caller supplies; the kind and the data come out of the database, and the URL comes out of notificationTargets. A handler that redirected to a form field would be an open redirect with a notification in front of it.
Read first and mark second, so a row that cannot be read leaves the badge alone. Marking a notification read that the reader is then not sent to would be the worst of both.
func (*Web) NotificationRead ¶ added in v0.2.0
func (h *Web) NotificationRead(w http.ResponseWriter, r *http.Request)
NotificationRead marks one read and returns to the list.
A form post rather than an hx-post, because the page it returns to has a different nav badge than the one it left — swapping a fragment would leave the count stale, which is the one number this page exists to make true.
func (*Web) NotificationReadAll ¶ added in v0.2.0
func (h *Web) NotificationReadAll(w http.ResponseWriter, r *http.Request)
NotificationReadAll empties the badge.
func (*Web) NotificationUnread ¶ added in v0.3.0
func (h *Web) NotificationUnread(w http.ResponseWriter, r *http.Request)
NotificationUnread puts one back in the unread list (M48).
The owner's note is the whole justification — *"No way to mark a read message as unread if it was accidentally marked as read"* — and this milestone is what makes the accident common, because opening a notification now marks it read on the way past.
func (*Web) NotificationsPage ¶ added in v0.2.0
func (h *Web) NotificationsPage(w http.ResponseWriter, r *http.Request)
NotificationsPage lists the signed-in user's own inbox.
func (*Web) OrganizationCreate ¶ added in v0.2.0
func (h *Web) OrganizationCreate(w http.ResponseWriter, r *http.Request)
OrganizationCreate provisions an organization and moves the browser into it.
The switch is the difference from the JSON endpoint, and it is the same trade the setup form makes: somebody who just created an organization meant to start using it, and the page they are on lists the *previous* organization's workspaces — so leaving them there would show them a list their new organization is not in. A failed switch is not undone; the organization exists either way, and the switcher in the chrome reaches it.
Two callers post here and a refusal has to go back to whichever one it came from: the workspaces page's organization form, and the page an account that belongs to nothing is held on (D36). Re-rendering the workspaces page for the second would fail on the way in — listing workspaces needs workspace.read, which an account with no membership does not hold — so the branch below is about which page exists for this reader, not about presentation.
func (*Web) OrganizationDelete ¶ added in v0.2.0
func (h *Web) OrganizationDelete(w http.ResponseWriter, r *http.Request)
OrganizationDelete tears down the organization the reader is acting in.
Afterwards they are somewhere else by definition, and which somewhere depends on what else they belong to — another organization, or nothing at all. Rather than work that out here, this hands the browser to /dashboard and lets the ordinary resolution decide: somebody with another membership lands on it, and somebody with none is met by RequireOrganization and offered one. The session needs no repair on the way, because sessions.workspace_id is SET NULL by the cascade and ResolveWorkspaceForUser answers again from scratch.
func (*Web) OrganizationNewPage ¶ added in v0.2.0
func (h *Web) OrganizationNewPage(w http.ResponseWriter, r *http.Request)
OrganizationNewPage is the whole product for an account that belongs to nothing.
It exists because D36 chose to let deletion orphan people rather than refuse on their behalf, and an orphaned account with no page to land on would be an error message where an empty state belongs. Everything else is refused while they are here — see RequireOrganization — so this page carries no navigation of its own beyond signing out.
Somebody who *does* belong to an organization is sent to /workspaces instead, which is where the same form lives for them, alongside the workspaces it would otherwise be missing. One form, two homes, rather than two forms.
func (*Web) PasswordChange ¶
func (h *Web) PasswordChange(w http.ResponseWriter, r *http.Request)
func (*Web) RequireOrganization ¶ added in v0.2.0
RequireOrganization sends an account that belongs to nothing to the page that offers it one, and lets everybody else past.
It is an affordance and not the authorization boundary, which is the distinction worth keeping straight: an identity with no organization holds an empty permission set, so every service call it could reach already refuses on the check it always made. What this adds is that the refusals are never seen — a page rendered for a workspace that does not exist is an error where the milestone asks for an empty state, and eight pages each discovering that separately is eight chances to get it wrong once.
Applied to the dashboard tree only. The JSON API needs no equivalent: its operations authorize on permissions, an orphaned caller holds none, and the handful of endpoints that are user-scoped rather than organization-scoped — the notification inbox, the workspace list — correctly answer with an empty list, which is the state rendered rather than an error.
func (*Web) RequireWebAuth ¶
RequireWebAuth is RequireAuth for pages: anonymous requests are sent to the login form with a way back, not handed a JSON problem they cannot read.
func (*Web) ResetPage ¶ added in v0.3.0
func (h *Web) ResetPage(w http.ResponseWriter, r *http.Request)
ResetPage shows the form the emailed link lands on.
The token is not checked here, for the reason VerifyPage does not check its own: doing so would make this page an oracle for which tokens exist, and the POST answers for all of them identically anyway. A GET that acted would also let a mail client's link scanner spend somebody's reset before they had read the message.
func (*Web) ResetSubmit ¶ added in v0.3.0
func (h *Web) ResetSubmit(w http.ResponseWriter, r *http.Request)
ResetSubmit writes the new password and ends every session on the account.
It lands on the sign-in form rather than starting a session, and that is a choice rather than a limitation: the reset revokes every session for the account, so signing this browser straight in would mean the one credential the recovery is meant to displace gets replaced by a session created in the same breath. Typing the new password once is the confirmation that it is known, and it is what the setup and invitation forms get for free by having the password in hand.
func (*Web) Root ¶
func (h *Web) Root(w http.ResponseWriter, r *http.Request)
Root sends / wherever makes sense for the visitor.
func (*Web) RuleCreate ¶ added in v0.2.0
func (h *Web) RuleCreate(w http.ResponseWriter, r *http.Request)
RuleCreate adds a rule from the link detail page.
func (*Web) RuleDelete ¶ added in v0.2.0
func (h *Web) RuleDelete(w http.ResponseWriter, r *http.Request)
RuleDelete removes a rule.
func (*Web) RuleToggle ¶ added in v0.2.0
func (h *Web) RuleToggle(w http.ResponseWriter, r *http.Request)
RuleToggle switches a rule on or off.
A separate action from editing, because "stop this rule for now" is the thing somebody reaches for when a campaign misfires, and making them open a form to do it is the difference between a control and a control they will use.
func (*Web) SetupSubmit ¶
func (h *Web) SetupSubmit(w http.ResponseWriter, r *http.Request)
func (*Web) SignupPage ¶ added in v0.2.0
func (h *Web) SignupPage(w http.ResponseWriter, r *http.Request)
SignupPage renders the public signup form.
A closed instance gets one refusal, on the GET, with no explanation of which of the two reasons applies — the mode, or the missing mailer. Refusing here rather than at the post is what keeps somebody from filling in a password and discovering at submit time that there was never a form to submit; which of the two bounds it is remains the operator's business and not a stranger's.
func (*Web) SignupSubmit ¶ added in v0.2.0
func (h *Web) SignupSubmit(w http.ResponseWriter, r *http.Request)
SignupSubmit takes the form and queues a verification link.
No session is created and no account exists when this returns. That is the difference from the setup form, which signs its user straight in: there the person had already proven they could reach the machine, and here the address is the thing being proven (D1).
func (*Web) ThemeSet ¶ added in v0.2.0
func (h *Web) ThemeSet(w http.ResponseWriter, r *http.Request)
ThemeSet stores the visitor's choice and returns them where they were.
A plain form POST with no JavaScript, and no account: the preference is per-browser by design. Two browsers signed into one account may disagree, which is correct — the person at each of them chose.
func (*Web) UpdateCheckAnswer ¶ added in v0.3.0
func (h *Web) UpdateCheckAnswer(w http.ResponseWriter, r *http.Request)
UpdateCheckAnswer records the answer to the prompt above (M55, D164).
**On the dashboard rather than on a settings page**, because the dashboard is where a sign-in lands — `Root` sends a signed-in visitor to `/dashboard` — so *at the first administrative sign-in* is where the question actually appears rather than where a route diagram says it does. The prompt stays until it is answered, so an administrator who signed in with a `?next=` and went straight somewhere else meets it the next time they are on the page. D161 refused a settings page and this is not one: it is one question, asked once, and `instance.AnswerUpdateCheck` refuses a second answer.
Both buttons post here and the value is what differs, so *no* costs the same one click *yes* does. A prompt whose refusal is harder than its acceptance is a prompt with a preferred answer, which is not what an operator is being asked to decide about their own egress.
An answer already given is not an error: the reader wanted the question settled and it is. They go back to the dashboard, where the prompt is now gone, which is the honest report of what happened.
func (*Web) VariantCreate ¶ added in v0.2.0
func (h *Web) VariantCreate(w http.ResponseWriter, r *http.Request)
VariantCreate adds an arm from the link detail page.
func (*Web) VariantDelete ¶ added in v0.2.0
func (h *Web) VariantDelete(w http.ResponseWriter, r *http.Request)
VariantDelete removes an arm.
func (*Web) VariantToggle ¶ added in v0.2.0
func (h *Web) VariantToggle(w http.ResponseWriter, r *http.Request)
VariantToggle switches an arm on or off. This is the feature flag.
func (*Web) VerifyPage ¶ added in v0.2.0
func (h *Web) VerifyPage(w http.ResponseWriter, r *http.Request)
VerifyPage shows the confirmation the emailed link lands on.
A page with a button rather than a link that acts, for the reason invitation redemption is a POST: mail clients and security scanners fetch the URLs in a message, and a GET that created an account would let a scanner finish somebody else's registration before they had read the mail.
The token is not checked here. Doing so would make this page an oracle for which tokens exist, and the POST answers for all of them identically anyway.
func (*Web) VerifySubmit ¶ added in v0.2.0
func (h *Web) VerifySubmit(w http.ResponseWriter, r *http.Request)
VerifySubmit completes the registration, creating the account. It is the only place in the product where an account comes into being with its address already proven.
It ends at the sign-in form rather than starting a session, and that is a consequence of the design rather than a choice made here: the password was hashed at the signup form and the plaintext never survived that request, so there is nothing to sign in with. The setup and invitation forms do sign somebody straight in, and both of them had the password in hand.
func (*Web) WebhookCreate ¶ added in v0.2.0
func (h *Web) WebhookCreate(w http.ResponseWriter, r *http.Request)
func (*Web) WebhookDelete ¶ added in v0.2.0
func (h *Web) WebhookDelete(w http.ResponseWriter, r *http.Request)
func (*Web) WebhookRotate ¶ added in v0.2.0
func (h *Web) WebhookRotate(w http.ResponseWriter, r *http.Request)
func (*Web) WebhookToggle ¶ added in v0.2.0
func (h *Web) WebhookToggle(w http.ResponseWriter, r *http.Request)
WebhookToggle is the pause switch, on its own form.
Its own action rather than a field on the edit form, for the reason the rule and split toggles have one: switching a misbehaving receiver off is what somebody reaches for first, and it must not require opening an editor.
func (*Web) WebhookUpdate ¶ added in v0.2.0
func (h *Web) WebhookUpdate(w http.ResponseWriter, r *http.Request)
func (*Web) WebhooksPage ¶ added in v0.2.0
func (h *Web) WebhooksPage(w http.ResponseWriter, r *http.Request)
func (*Web) WorkspaceCreate ¶ added in v0.2.0
func (h *Web) WorkspaceCreate(w http.ResponseWriter, r *http.Request)
WorkspaceCreate adds a workspace to the caller's organization.
func (*Web) WorkspaceDefault ¶ added in v0.2.0
func (h *Web) WorkspaceDefault(w http.ResponseWriter, r *http.Request)
WorkspaceDefault sets, or clears, the pinned default workspace.
An empty workspace_id is the *Last-Used* option rather than a missing field: the control's first choice is the derived behaviour, and choosing it has to be a way back from having pinned something.
func (*Web) WorkspaceDelete ¶ added in v0.2.0
func (h *Web) WorkspaceDelete(w http.ResponseWriter, r *http.Request)
WorkspaceDelete removes a workspace, and refuses while it holds any link.
func (*Web) WorkspaceRename ¶ added in v0.2.0
func (h *Web) WorkspaceRename(w http.ResponseWriter, r *http.Request)
WorkspaceRename changes a workspace's name.
func (*Web) WorkspaceSwitch ¶ added in v0.2.0
func (h *Web) WorkspaceSwitch(w http.ResponseWriter, r *http.Request)
WorkspaceSwitch moves the browser into another workspace and returns it where it was.
A plain form post, same as the appearance control. The switcher sits in the page chrome, so the destination comes back on the form rather than from Referer: switching on the links page should leave you on the links page, looking at the other workspace's links.
func (*Web) WorkspacesPage ¶ added in v0.2.0
func (h *Web) WorkspacesPage(w http.ResponseWriter, r *http.Request)
WorkspacesPage lists the workspaces of the caller's organization.
type WorkspaceAPI ¶ added in v0.2.0
WorkspaceAPI is the switcher, as a program sees it.
The dashboard's switcher posts forms at the handlers in web_workspaces.go and both reach the same three service calls, so a client can do everything the nav dropdown and the account setting can.
func (*WorkspaceAPI) List ¶ added in v0.2.0
func (a *WorkspaceAPI) List(w http.ResponseWriter, r *http.Request)
List returns every workspace the caller may act in, flagging which one this request is in and which one new sessions start in.
No pagination. A person's memberships are a handful of rows by construction — a cursor here would be machinery for a page that cannot fill.
func (*WorkspaceAPI) SetDefault ¶ added in v0.2.0
func (a *WorkspaceAPI) SetDefault(w http.ResponseWriter, r *http.Request)
SetDefault pins where new sessions start, or clears the pin with a null.
func (*WorkspaceAPI) Switch ¶ added in v0.2.0
func (a *WorkspaceAPI) Switch(w http.ResponseWriter, r *http.Request)
Switch moves the calling session into a workspace.
204 rather than the new identity: the caller already knows what it asked for, and the next request resolves the change anyway. Refused for an API key, for the two reasons SwitchWorkspace gives — neither of which is that a key would be unaffected by the result.
Source Files
¶
- api_account.go
- api_audit.go
- api_auth.go
- api_automation.go
- api_campaigns.go
- api_disputes.go
- api_domains.go
- api_feeds.go
- api_folders.go
- api_instance.go
- api_invitations.go
- api_keys.go
- api_links.go
- api_mfa.go
- api_notifications.go
- api_qr.go
- api_recovery.go
- api_rules.go
- api_split.go
- api_stats.go
- api_team.go
- api_webhooks.go
- api_workspaces.go
- docs.go
- health.go
- limits.go
- middleware.go
- notification_target.go
- problem.go
- redirect.go
- redirect_gates.go
- redirect_hosts.go
- redirect_rules.go
- redirect_split.go
- reserved.go
- rootredirect.go
- router.go
- theme.go
- timing.go
- web.go
- web_automation.go
- web_campaigns.go
- web_disputes.go
- web_domains.go
- web_feeds.go
- web_folders.go
- web_invitations.go
- web_keys.go
- web_links.go
- web_mfa.go
- web_notifications.go
- web_qr.go
- web_recovery.go
- web_rules.go
- web_signup.go
- web_split.go
- web_team.go
- web_webhooks.go
- web_workspaces.go