link

package
v0.3.0 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Aug 18, 2026 License: MIT Imports: 31 Imported by: 0

Documentation

Overview

Package link owns link and tag business logic.

Index

Constants

View Source
const (
	PermAutomationRead  = "automation.read"
	PermAutomationWrite = "automation.write"
)

Permissions this file enforces.

Their own rather than `links.*`, which is the fork D75 and D80 have already been at. A QR code is a property of a link, so whoever may edit the link may edit it. An automation rule is a property of nothing: it runs unattended, on a clock, and can archive links and make this server connect to an address the workspace chose. See decisions.md for which limb of D18 the write half matched.

View Source
const (
	// Unappealable.
	RulePrivateAddress  = "private_address"
	RuleSchemeForbidden = "scheme_not_allowed"

	// High confidence.
	RuleEmbeddedHost = "embedded_host"

	// Low confidence, from the runtime list.
	RuleOperatorBlocklist = "operator_blocklist"
	RuleShortenerChain    = "shortener_chain"

	// Low confidence, computed.
	RulePunycodeHomograph = "punycode_homograph"
	RuleURLCredentials    = "url_credentials"

	// RuleFeedReputation is the opt-in third-party feed (M32). Low confidence
	// like everything else that guesses, and low confidence for a second reason
	// the others do not have: the claim is somebody else's, made about a URL
	// they were sent, and this instance cannot check their working.
	RuleFeedReputation = "feed_reputation"
)

Rules, one per way a destination can be refused. Named constants because they are stored in the audit log, returned in a 422 and read by operators, so a typo is a silently different vocabulary rather than a compile error.

View Source
const (
	// SourceEnv is LINKCTRL_DESTINATION_BLOCKLIST, rewritten at every boot.
	SourceEnv = "env"

	// SourceReview is what a person added — the column default, and what M31's
	// review queue will write.
	SourceReview = "review"

	// SourceShortener is the known URL-shortener hosts, seeded by migration
	// 01500 rather than compiled into the binary (D39). A list is compiled when
	// overruling it should be hard; a shortener host is neither a structural
	// claim nor an authoritative one, and a match on it only raises a
	// low-confidence flag the owner may overrule.
	SourceShortener = "shortener"
)

Sources a row of the runtime list can carry, and the vocabulary the `source` column is written and reconciled against.

Every reconciliation this program runs is scoped to exactly one of these, which is what keeps them from erasing each other: the boot-time rewrite of the environment list deletes SourceEnv rows and nothing else, so neither a host an operator added by hand nor a seeded shortener can be retired by a restart.

View Source
const (
	PermRead      = "links.read"
	PermCreate    = "links.create"
	PermUpdate    = "links.update"
	PermDelete    = "links.delete"
	PermTagsRead  = "tags.read"
	PermTagsWrite = "tags.write"
	// PermDomainsWrite guards settings that apply to the hostname rather than to
	// a workspace. One host serves every workspace on the instance, so this is a
	// wider grant than links.update despite touching fewer rows.
	PermDomainsWrite = "domains.write"
)

Permissions this service enforces. Named constants so a typo is a compile error rather than a silently-always-false check.

View Source
const (
	PermWebhooksRead  = "webhooks.read"
	PermWebhooksWrite = "webhooks.write"
)

Permissions this file enforces.

Their own rather than `links.*`, which is where this parts company with QR codes and campaigns (D75). Those are properties of a link, so whoever may edit the link may edit them. A webhook is a standing instruction to make this server connect somewhere, which is a different power from editing what a visitor's browser is sent to — see decisions.md.

View Source
const BlockedAuditRatePerMin = 10

BlockedAuditRatePerMin bounds how many audit rows one actor can provoke for one refusal code in a minute (F14).

A constant rather than a setting. The number an operator would tune it with is not one they have — nobody knows how often their own members typo a blocked destination — and the thing it protects against is a loop, which any value in this range stops equally. Ten is comfortably above the handful a person produces by accident in a minute and far below what a script does in a second.

It bounds *rows*, never refusals: a destination is blocked identically whether or not its audit row is written.

View Source
const DefaultSignatureTTL = 24 * time.Hour

DefaultSignatureTTL is what a request that names no lifetime gets.

View Source
const DefaultVerifyGrace = 24 * time.Hour

DefaultVerifyGrace is the grace window an operator who sets nothing gets.

**Twenty-four hours, and the number is a judgement rather than a measurement.** It is bounded below by what a human can act on: the workspace is told at the first failure, and a window shorter than a working day would notify somebody at 02:00 and stop serving their links before they read it, which is a warning that only exists to have technically been given. It is bounded above by what it costs — for the length of the window this instance keeps serving a hostname whose DNS its owner may no longer control — and a week of that is not a grace period, it is a policy of ignoring the answer.

One day is also the largest window that can be stated in the runbook without arithmetic, which D70 requires of it.

View Source
const DefaultVerifyInterval = time.Hour

DefaultVerifyInterval is how often the leader re-checks.

**Hourly.** The point of the cadence is to make a single failure weak evidence and a sustained one strong: at this rate a domain must fail twenty-four consecutive checks, spread over a day, before serving stops, and a resolver blip — which is measured in seconds and minutes — cannot produce that. Faster would buy nothing, because the window is a day either way, and would multiply the queries a large instance sends to other people's nameservers.

View Source
const MaxSignatureTTL = 30 * 24 * time.Hour

MaxSignatureTTL bounds how long a signed URL may be valid for.

Thirty days, and the ceiling is the point rather than the number. A signature is a bearer capability: whoever holds the URL can follow the link, and nothing about the request identifies them. A signature that never expired would be a permanent secret pasted into whatever chat window it was shared in, which is the property the expiry exists to remove. An owner who wants a link that works forever already has one — an unsigned link.

View Source
const TrashRetentionDays = 30

TrashRetentionDays is how long a soft-deleted link stays restorable.

Variables

This section is empty.

Functions

func Defang added in v0.2.0

func Defang(raw string) string

Defang renders a hostile URL inert for storage and for display.

Two transformations, and both are needed. Percent-escaping everything outside defangSafe makes the string inert as markup, so a destination carrying "<script>" cannot become one wherever it is rendered — including in a consumer written after this, which is the one that will forget. Bracketing the scheme delimiter and the dots makes it inert as a link, so nothing auto-links it, no mail client makes it clickable, and nobody follows it by reflex while reading the record of somebody else being refused.

Reversible by hand and lossless in the sense that matters: an operator reading the audit log can still see exactly which host was attempted. Non-ASCII is escaped rather than shown, which costs readability on an internationalized host and buys certainty — a right-to-left override or a homograph rendered faithfully into a console is the display attack this function exists to stop.

func HostCandidates added in v0.2.0

func HostCandidates(host string) []string

HostCandidates is a host and every parent of it, longest first.

The label-boundary rule the environment blocklist has always had, expressed as the set of things to ask the database for: blocking "evil.example" refuses "login.evil.example" and does not refuse "notevil.example", because the candidates for the second are {notevil.example, example} and neither is the listed entry. Asking for all of them at once makes the match an index probe rather than a scan with a LIKE.

Exported for M31's review queue. A decision to allow a destination has to remove the row that actually refused it, which may be a parent of the host that was typed — so the queue asks the same question this package does, with the same rule, rather than inventing a second matching rule that could drift.

func HostOf

func HostOf(rawURL string) string

HostOf extracts the lowercase host, stored alongside the URL so the hot path and reporting never have to re-parse.

func IsRestrictedAddr added in v0.2.0

func IsRestrictedAddr(addr netip.Addr) bool

IsRestrictedAddr is isRestricted, exported for the one caller outside this package that needs the same answer about a *resolved* address.

internal/webhook calls it from the dialer's Control hook, after DNS has answered and before connect(2), which is the only place a rebinding check can stand. It is deliberately the same function and not a second list: two definitions of "private address" in one program is a drift bug waiting for the day somebody adds a range to one of them.

Nothing else should reach for this. A *destination* — anything a visitor's browser will be sent to — goes through Service.checkDestination, and a caller that took this predicate instead would inherit the SSRF refusals while skipping every tier above them, which is exactly the bypass TestEveryDestinationSurfaceGoesThroughTheCheck exists to catch.

func LoadRootRedirectWith added in v0.2.0

func LoadRootRedirectWith(ctx context.Context, q *dbgen.Queries) (string, error)

LoadRootRedirectWith is the same read with the caller choosing the pool.

It exists so the redirect tree can refill its root cache from the **redirect** pool. `main.go` states the guarantee that pool exists for — *"so a slow analytics query on the application pool cannot leave a redirect waiting to acquire a connection"* — and until F48 the one redirect-tree path that reads Postgres on a request acquired from the application pool instead, because the only loader available was a method on a service built on it. Two plain reads with no transaction and no service state, so the queries handle is the whole dependency.

func QRContent added in v0.2.0

func QRContent(shortURL, slug string) string

QRContent is what one of a link's QR codes encodes: its short URL, carrying the source parameter that makes a scan tell the analytics what it is, and — for a named code — the slug that says which code it was (M50).

Exported because two surfaces render a code — the API and the dashboard — and a second copy of this concatenation is a second answer to "what does the picture say". M50 gave that property a second job: the redirect path resolves the slug this function writes, so the encoded payload and the redirect's expectation cannot drift apart.

**The empty slug adds nothing.** The default code's payload is byte for byte what every code this product drew before M50 carried, which is what makes an already-printed picture go on being counted as the same code it always was.

func QROutputSize added in v0.3.0

func QROutputSize(content string, style qr.Style) int

QROutputSize is the pixel size a style draws a piece of content at, or 0 for content that cannot be encoded at all.

Zero rather than an error, because every caller is answering "how big is this picture" about a picture it is already reporting a failure for by other means: the panel shows its own message and the API's JSON view is not the surface that draws anything. A size beside a code that does not exist is the one answer that would be actively wrong.

Exported for the same reason QRContent is — two surfaces ask, and a second copy of the arithmetic is a second answer.

Through qr.Drawn since D184, so the size is measured against the symbol the resolved level produces rather than against the floor the row names. The two come apart wherever the floor is above the free level — every logo'd code — and asking through the one function is what keeps that a property of the encoder rather than of each caller remembering it.

func ValidateDestination

func ValidateDestination(raw string, p DestinationPolicy) (string, error)

ValidateDestination checks a destination URL and returns its normalized form.

This is an allowlist, not a blocklist, and that is the whole design. A blocklist of dangerous schemes is a game you lose: javascript:, data:, vbscript:, file:, intent:, and whatever the next browser ships. Permitting only http and https means a new scheme is refused by default.

Known limitation, deliberately not papered over: blocking private literals does not defend against DNS rebinding, where a hostname resolves to a public address at creation and a private one when a visitor follows the link. Defending against that requires resolving at redirect time on the hot path, which cannot be afforded, or an egress policy outside this process. Recorded in docs/build-notes/SECURITY.md rather than pretended away.

This is the unappealable tier and only the unappealable tier. It is called from exactly one place — Service.checkDestination — and that is enforced by TestEveryDestinationSurfaceGoesThroughTheCheck rather than by discipline, because a caller that reached this function directly would inherit the SSRF refusals while silently skipping every tier above them.

Types

type Block added in v0.2.0

type Block struct {
	Tier Tier
	Rule string
	// Detail is what the person who typed the URL is told. It never echoes the
	// destination back: the reason code is the machine-readable half, and every
	// place a hostile URL gets rendered is a place it has to be defanged first.
	Detail string
}

Block is a refusal by one of the two appealable tiers.

There is no counterpart type meaning "allowed", and that is structural rather than an accident of naming: nothing in this package returns permission. A destination is accepted by surviving every check, so no list entry and no future review path can hand one an approval that the unappealable tier would then have to honour.

func (Block) Error added in v0.2.0

func (b Block) Error(field string) domain.FieldError

Error renders a block as the field error an API or a form receives.

type Config

type Config struct {
	Policy DestinationPolicy
	// Aliases carries the operator's reserved-word additions and the profanity
	// switch. The zero value is the safe default: built-in list, filter on.
	Aliases alias.Policy
	BaseURL string
	Cache   Invalidator
	// SplitHosts mirrors config.SplitHosts. The root-redirect setting is refused
	// when false, because there the root is the dashboard.
	SplitHosts bool
	RootCache  RootInvalidator
	// Audit records administrative changes. Nil records nothing.
	Audit audit.Recorder
	// BlockedAuditLimit bounds how often one actor can make the *same* refusal
	// write an audit row. Nil leaves the write unbounded, which is the shipped
	// M30 behaviour and what a runner built without a limiter gets.
	//
	// It exists because `destination.blocked` is the one audited action that
	// records something which did **not** happen: every other one is bounded by
	// a state change somebody had the authority to make, and this one is bounded
	// by how fast a caller can be refused. A holder of an ordinary editor role
	// could loop `POST /api/v1/links` with `http://127.0.0.1/` and add a row per
	// request, each carrying up to 2 KiB of defanged URL, on an instance whose
	// audit retention defaults to keep-forever (F14).
	//
	// **Keyed per actor *and per reason*, which is the part that matters.** A
	// per-actor bound alone would let somebody bury the refusal that mattered
	// under a flood of a different one — the attacker chooses the noise, so the
	// budget has to be per-reason or it is a suppression tool. A refusal code
	// nobody has provoked before is therefore always recorded, whatever else is
	// being hammered.
	BlockedAuditLimit KeyLimiter
	// Feed is the opt-in third-party reputation check. Nil is the default and
	// the only state in which this program sends nothing anywhere; see
	// Service.askFeed and docs/build-notes/decisions.md, D40.
	Feed FeedChecker
	// FeedMetrics counts feed checks, including the failures that fail open.
	// Nil counts nothing.
	FeedMetrics FeedObserver
	// Hasher hashes link passwords (M35). Nil refuses to set one.
	Hasher *auth.Hasher
	// Gates reads the durable click budget and the workspace signing secret
	// (M35). Nil leaves both unavailable.
	Gates GateReader
	// DNS answers the custom-domain challenge (M40). Nil refuses verification.
	DNS TXTLookup
	// Hosts broadcasts a change to the verified hostname set across replicas.
	// Nil keeps the change local, which is the pre-pub/sub behaviour.
	Hosts HostInvalidator
	// DomainNotify warns a workspace whose hostname is failing verification.
	// Nil warns nobody.
	DomainNotify DomainNotifier
	// VerifyGrace is how long a failing hostname keeps serving (D70). Zero uses
	// DefaultVerifyGrace.
	VerifyGrace time.Duration
	// Events queues webhook deliveries (M42). Nil emits nothing.
	Events WebhookEmitter
	// Log receives the warning when an audit write fails. Nil uses the default
	// logger, so a dropped record is never silent.
	Log *slog.Logger
}

type CreateAutomationRuleInput added in v0.2.0

type CreateAutomationRuleInput struct {
	Name          string
	Trigger       string
	TriggerConfig domain.AutomationTriggerConfig
	Actions       []string
	// Enabled is whether the scheduler evaluates it. A disabled rule is skipped
	// by ListDueAutomationRules rather than evaluated and discarded.
	Enabled bool
}

CreateAutomationRuleInput is a new standing instruction.

type CreateCampaignInput added in v0.2.0

type CreateCampaignInput struct {
	Name string
	// Slug is optional; an empty one is derived from the name.
	Slug        string
	Description string
	StartsAt    *time.Time
	EndsAt      *time.Time
}

CreateCampaignInput is a new campaign.

type CreateFolderInput added in v0.2.0

type CreateFolderInput struct {
	Name string
	// ParentID is the folder this one goes inside, or nil for the top level.
	ParentID *uuid.UUID
}

CreateFolderInput is a new folder.

type CreateInput

type CreateInput struct {
	URL         string
	Alias       string // optional; generated when empty
	Title       string
	Description string
	Tags        []string
	ExpiresAt   *time.Time
	// ForwardQuery merges the visitor's query string into the destination.
	// Off by default; the destination's own parameters always win on conflict.
	ForwardQuery bool
	// ForwardPath appends the visitor's extra path segments to the destination.
	// Off by default: with it on the alias answers every path beneath itself,
	// and that is a decision about the link's whole namespace rather than about
	// one URL.
	ForwardPath bool

	// FolderID files the new link (M38). Nil leaves it unfiled, which is where
	// every link created before folders existed still is.
	FolderID *uuid.UUID

	// CampaignID labels the new link (M41). Nil leaves it unlabelled. A folder
	// and a campaign are different questions — where the link lives and what it
	// is for — so a link may carry both, one or neither.
	CampaignID *uuid.UUID

	// DomainID names the hostname the link is served on (M40). Nil takes the
	// workspace's own default, which is the instance default until the workspace
	// has verified a hostname of its own.
	//
	// It must be a domain this workspace may use *and* verified. Both halves are
	// checked, and the second is the one that matters: a link on an unverified
	// hostname would be a short URL the product handed somebody that resolves
	// nowhere, on a name this instance has no evidence they control.
	DomainID *uuid.UUID

	// The gates (M35). Each is off unless asked for, so a link created without
	// them is byte-for-byte the link this service created before they existed.
	//
	// Password is write-only in every direction: it is hashed here and nothing
	// reads it back. MaxClicks and OneTime are the same gate with different
	// numbers — see gate.ClickLimit — and RequireSignature refuses any request
	// without a valid HMAC for the alias.
	Password         string
	MaxClicks        *int64
	OneTime          bool
	RequireSignature bool
}

CreateInput describes a new link.

type CreateRuleInput added in v0.2.0

type CreateRuleInput struct {
	// URL is where a matching visitor is sent. Judged by every tier before the
	// rule exists.
	URL string
	// Priority orders evaluation: lower wins. Defaulted to the column's 100 when
	// left at zero, so a caller that does not care gets the same priority as
	// every other caller that does not care, and ties break on creation order.
	Priority int32
	// Conditions is the condition set, already parsed and validated.
	Conditions domain.RuleConditions
	// Enabled is whether the rule is evaluated at all. A rule created disabled is
	// a rule somebody is drafting.
	Enabled bool
}

CreateRuleInput is a new rule.

type CreateVariantInput added in v0.2.0

type CreateVariantInput struct {
	// Kind is `weighted` or `sequential` for an arm, or `fallback`.
	Kind string
	// URL is where this arm sends people. Judged by every tier before the arm
	// exists.
	URL string
	// Weight is the arm's share of a weighted split. Ignored for the other two
	// kinds, which store the column's default and never read it.
	Weight int32
	// Enabled is whether the arm receives traffic at all. This is the feature
	// flag: an arm switched off stops being chosen on the next resolve and the
	// remaining arms re-share its traffic, with nothing deleted and no
	// attribution lost.
	Enabled bool
}

CreateVariantInput is a new split arm.

type CreateWebhookInput added in v0.2.0

type CreateWebhookInput struct {
	// URL is where events are POSTed. Judged by every tier before the row
	// exists, and judged again at the address the name resolves to before any
	// socket is opened.
	URL string
	// Events is the subscription, from domain.WebhookEvents.
	Events []string
	// Description is what an operator calls this webhook in the list.
	Description string
	// Enabled is whether the webhook receives anything. A disabled webhook is
	// skipped by the fan-out query rather than delivered and discarded, so
	// switching one off stops queueing rows as well as stopping deliveries.
	Enabled bool
}

CreateWebhookInput is a new registration.

type DestinationDisclosure added in v0.2.0

type DestinationDisclosure struct {
	// Embedded rather than a named field, so every field the feed disclosure has
	// always published stays exactly where an API client already found it, and
	// the new channel arrives as an addition rather than as a reshuffle. The
	// template reads .Disclosure.Enabled unchanged for the same reason.
	feed.Disclosure
	// Webhooks is the workspace's own channel.
	Webhooks WebhookDisclosure `json:"webhooks"`
}

DestinationDisclosure is what happens to the destinations an actor's workspace submits, as the `/feeds` page and `GET /api/v1/feeds` print it.

**Two channels, and they are not the same kind of thing.** The reputation feed is the operator's, instance-wide, and off unless somebody set `FEED_URL`. Outbound webhooks are a *workspace's*, registered by anybody holding `webhooks.write` there, with no operator switch anywhere in the path (internal/config: "there is no switch: webhooks are a workspace feature rather than an operator one"). One page answers both because one question is being asked — *does what I type here go anywhere* — and until M45 it answered only half of it, in a green panel, to every signed-in user (F135).

The feed half stays instance-scoped and the webhook half is scoped to the actor's own workspace. Neither half may be read as the other: this instance having no feed says nothing about the workspace next door's webhooks, and this workspace having no webhook says nothing about anybody else's.

type DestinationPolicy

type DestinationPolicy struct {
	// Schemes is the allowlist. Anything outside it is refused. Config
	// validation confines it to a subset of {http, https}, so it can narrow the
	// unappealable tier and never widen it.
	Schemes   []string
	MaxLength int
}

DestinationPolicy governs which URLs a link may point at.

Note what is not here any more. This struct used to carry BlockPrivateIPs and BlockedHostSuffixes, and M30 took both away for opposite reasons.

BlockPrivateIPs was an override switch on the unappealable tier: setting it false accepted 169.254.169.254, which is the SSRF this validator exists to prevent, decided by an operator on behalf of visitors who never agreed to it. The refusals below are now unconditional and there is no field through which they could be turned off — asserted by TestUnappealableTierHasNoOverrideSwitch, which walks this struct by reflection and fails when it grows a field.

BlockedHostSuffixes left for the opposite reason: it was the low-confidence tier before there was one, and it now lives in Postgres where the instance owner can change it without a restart. LINKCTRL_DESTINATION_BLOCKLIST still works and still means the same thing; it seeds those rows at boot.

func DefaultDestinationPolicy

func DefaultDestinationPolicy() DestinationPolicy

type Domain added in v0.2.0

type Domain struct {
	ID       uuid.UUID `json:"id"`
	Hostname string    `json:"hostname"`
	// Scope is who owns it: "instance", "organization" or "workspace".
	Scope DomainScope `json:"scope"`
	// IsDefault marks the instance default, the hostname every workspace's links
	// are on today.
	IsDefault bool `json:"is_default"`
	// Verified is the gate. False means no router resolves an alias on this
	// hostname, whoever points DNS at this instance.
	Verified   bool       `json:"verified"`
	VerifiedAt *time.Time `json:"verified_at,omitempty"`
	// SSLStatus is what this instance last recorded about the certificate:
	// `none` until verified, `pending` once it will answer Caddy's on-demand
	// ask, `active` once that ask has been answered. It is never more than that,
	// because the app does not speak ACME (decision D3) and the certificate is
	// Caddy's.
	SSLStatus string `json:"ssl_status"`
	// RootRedirectURL is where this hostname's own root sends a visitor. Empty
	// answers 404.
	RootRedirectURL string `json:"root_redirect_url,omitempty"`
	// Verification is the DNS challenge and the state of the last check. Absent
	// on the instance default, which is not verified by anybody.
	Verification *DomainVerification `json:"verification,omitempty"`
	// LinkCount is how many links are on it, which is what deleting one is
	// refused for.
	LinkCount int64 `json:"link_count"`
	// Manageable reports whether *this* actor may rename or delete it. A
	// rendering hint and never authorization: every write re-judges on arrival.
	Manageable bool      `json:"manageable"`
	CreatedAt  time.Time `json:"created_at"`
	UpdatedAt  time.Time `json:"updated_at"`
}

Domain is a hostname as the dashboard and the API see it.

The settings columns — root redirect, bot blocking — are deliberately absent. They belong to the instance default and are read and written through DomainSettings; putting them on a registered hostname would be configuring how something serves before anything serves it.

type DomainCheckSummary added in v0.2.0

type DomainCheckSummary struct {
	Checked    int
	Verified   int
	Failing    int
	Unverified int
}

DomainCheckSummary is what one re-verification pass did, for the job's log.

type DomainNotifier added in v0.2.0

type DomainNotifier interface {
	WarnDomainFailing(ctx context.Context, orgID uuid.UUID, workspaceID *uuid.UUID,
		hostname, reason string, stopsAt time.Time) error
	WarnDomainUnverified(ctx context.Context, orgID uuid.UUID, workspaceID *uuid.UUID,
		hostname, reason string) error
}

DomainNotifier warns a workspace that its hostname is in trouble.

Declared here and implemented by internal/notify, the same shape the audit recorder has: this package decides *when* somebody must be told and knows nothing about inboxes or mail. Nil sends nothing, and then the grace window still runs — the warning is what makes the stop fair, not what makes it work.

type DomainScope added in v0.2.0

type DomainScope string

DomainScope names which of D68's three legal ownership states a row is in.

const (
	// ScopeInstance is the default domain: both owner columns NULL, shared by
	// every workspace on the instance.
	ScopeInstance DomainScope = "instance"
	// ScopeOrganization is a domain owned by an organization and usable by every
	// workspace in it. Legal in the schema, and nothing registers one at M39 —
	// the surface here is per workspace, which is what the scope row promised.
	ScopeOrganization DomainScope = "organization"
	// ScopeWorkspace is a domain one workspace owns and administers.
	ScopeWorkspace DomainScope = "workspace"
)

type DomainSettings

type DomainSettings struct {
	Hostname string `json:"hostname"`
	// RootRedirectURL is where https://<link host>/ sends a visitor. Empty means
	// the root answers 404, which is the default and reveals nothing.
	RootRedirectURL string `json:"root_redirect_url,omitempty"`
	// SplitHosts reports whether the setting is in effect at all. On a
	// single-host deployment the root belongs to the dashboard.
	SplitHosts bool `json:"split_hosts"`

	// BlockBots is the domain's own answer, inherited by every link that has not
	// said otherwise. BlockBotsEnforced additionally overrules the ones that
	// have. Unlike the root redirect, neither depends on split hosts: short
	// links are served on this instance either way, and so are the crawlers
	// asking for them.
	BlockBots         bool `json:"block_bots"`
	BlockBotsEnforced bool `json:"block_bots_enforced"`
}

DomainSettings is what an operator can configure about the hostname short links are served on.

type DomainVerification added in v0.2.0

type DomainVerification struct {
	RecordType string `json:"record_type"`
	RecordName string `json:"record_name"`
	RecordData string `json:"record_data"`
	// CheckedAt is when the last check ran, whatever it concluded. Absent means
	// none has.
	CheckedAt *time.Time `json:"checked_at,omitempty"`
	// FailingSince anchors the grace window. Absent means the last check passed.
	FailingSince *time.Time `json:"failing_since,omitempty"`
	// StopsAt is when serving stops if nothing changes. Absent unless the domain
	// is both serving and failing, because it is a threat only in that state.
	StopsAt *time.Time `json:"stops_at,omitempty"`
	// Error is what the last failed check said, in the sentence the page shows.
	Error string `json:"error,omitempty"`
}

DomainVerification is the challenge as the dashboard and the API print it.

The record name and value are given in full, because the person reading this is about to paste them into a DNS provider's form and reconstructing `_linkctrl-challenge.` + hostname by hand is exactly where a typo lands.

type FeedChecker added in v0.2.0

type FeedChecker interface {
	Check(ctx context.Context, destination string) (feed.Result, error)
	Name() string
	// Describe is what the instance tells its users it is doing. On this
	// interface rather than read from configuration so that the disclosure and
	// the sending come from one object: a page assembled from the environment
	// could say "on" about a client that was never built.
	Describe() feed.Disclosure
}

FeedChecker is internal/feed's client, as this package needs it.

An interface declared by the consumer, so the dependency is two methods wide and a test answers with a table instead of an HTTP server. It is also what makes "with the feature off, zero destination URLs leave the instance" a structural claim: off is a nil interface, not a false flag, so there is no branch to get wrong and nothing to construct.

type FeedObserver added in v0.2.0

type FeedObserver interface {
	ObserveFeedCheck(result string)
}

FeedObserver counts checks. internal/observability implements it; nil counts nothing, which is what the CLI and most tests run with.

type GateReader added in v0.2.0

type GateReader interface {
	Budget(ctx context.Context, linkID uuid.UUID) (int64, *time.Time, error)
	EnsureSecret(ctx context.Context, workspaceID uuid.UUID) ([]byte, error)
}

GateReader is what the management surfaces need from internal/gate: the exact budget a gated link has spent, and the workspace key a signed URL is made with.

An interface rather than the concrete service, so internal/link does not import a package that imports it back through the redirect path.

type HostInvalidator added in v0.2.0

type HostInvalidator interface {
	InvalidateHosts(ctx context.Context)
}

HostInvalidator tells every replica that the verified set has changed.

Nil is valid and means this process is the only one that matters — the CLI, most tests — and then a verification is visible here and nowhere else, which is the pre-pub/sub behaviour rather than a fault.

type Invalidator

type Invalidator interface {
	InvalidateAlias(ctx context.Context, domainID uuid.UUID, alias string)
	InvalidateDomain(ctx context.Context, domainID uuid.UUID)
}

Invalidator clears cached snapshots when a link changes. The redirect cache implements it in M7; a nil Invalidator is valid and means "no cache".

InvalidateDomain is the M32.5 addition and it is on the same interface rather than a second one, because a caller that holds a cache it can only half invalidate is a caller that will eventually forget which half. It clears every alias on the domain, which is what a domain-level setting change requires: the cached snapshot carries the domain's bot policy so the redirect path needs no second lookup, and the bill for that arrives here.

type KeyLimiter added in v0.2.0

type KeyLimiter interface {
	// AllowKey reports whether this key has budget left, and consumes one unit
	// when it does. The duration is how long until the next unit, and is unused
	// here: a suppressed audit row is not retried and nobody is being told to
	// wait for it.
	AllowKey(key string) (bool, time.Duration)
}

KeyLimiter is the slice of internal/ratelimit this package needs.

Declared here rather than imported so a test satisfies it with a counter, and so "no limiter configured" is a nil interface rather than a flag every call site has to remember to check — the same shape Enqueuer takes in internal/invite.

type LinkDomainBots added in v0.2.0

type LinkDomainBots struct {
	Hostname          string
	BlockBots         bool
	BlockBotsEnforced bool
}

LinkDomainBots is the bot policy of the domain one link is served on, and the hostname to name when explaining it.

type LogoFit added in v0.3.0

type LogoFit struct {
	// SourceWidth and SourceHeight are what was uploaded, in pixels.
	SourceWidth, SourceHeight int
	// Width and Height are what is stored.
	Width, Height int
}

LogoFit is what an upload became, and what it was.

**The pair, not a boolean**, for the reason the size control reports both numbers rather than "snapped": somebody who uploaded artwork and got a stored image at a different size is owed the two figures, and a flag would leave the page saying that *something* happened. qr.FitStoredLogo is where the second pair comes from; this type is only how it reaches a handler without the bytes coming with it.

func (LogoFit) Resampled added in v0.3.0

func (f LogoFit) Resampled() bool

Resampled reports whether the upload had to be shrunk to be stored.

type QRCode added in v0.2.0

type QRCode struct {
	// ID is the stored row, and the zero uuid means there is no row — a default
	// code nobody has styled or named yet. It is what the per-code API paths
	// address, and it is absent from the JSON for an unstored code rather than
	// answering with a uuid nothing can be done with.
	ID     uuid.UUID `json:"id,omitempty"`
	LinkID uuid.UUID `json:"link_id"`
	// Slug is the identity that travels in the payload. It is generated, never
	// chosen: it is printed, so a workspace-supplied one would be a name somebody
	// has to keep unique across a link's codes and correct across every copy
	// already in the world.
	//
	// **Empty only for a link's single code** (D183). It used to be the default
	// code's identity, and that is what made the default undeletable; the
	// identity is Default below. A code gains a slug when a second one appears
	// beside it, because that is when there is something to tell it apart from —
	// before then it is the link's default by arithmetic and its payload is the
	// one every already-printed picture carries.
	Slug string `json:"slug"`
	// Default says whether an untagged scan resolves through this code (D183).
	//
	// True for exactly one of a link's codes, which
	// `qr_codes_link_default_key` (04400) is what makes true. Also true for the
	// synthesised code above: a link's default exists whether or not a row holds
	// it, and reporting false for the only code a link has would be reporting
	// that the link has no default at all.
	Default bool `json:"default"`
	// Label is what a person reads in the list. Free text, never in a URL, never
	// in the picture, and never seen by the redirect path.
	Label string `json:"label"`
	// Content is exactly what the picture encodes, including the source
	// parameter. Returned so a client can see what a scanner will read rather
	// than having to reconstruct it.
	Content string   `json:"content"`
	Style   qr.Style `json:"style"`
	// Stored is false for a link whose code has never been styled, which renders
	// at the default rather than not at all.
	Stored bool `json:"stored"`
	// Size is the output size in pixels this style draws this content at (M49).
	//
	// **Derived, never stored.** `qr_codes.style` holds a quiet zone in modules
	// and a scale in pixels per module, and how many pixels those come to
	// depends on how many modules the content encodes to — a longer alias is a
	// bigger matrix at the same style. So the number is computed on every read,
	// which is also what makes a style written before M49 read forward: the size
	// it means is the one its margin and scale already produce.
	//
	// It is in the API's answer as well as on the dashboard because the size is
	// now the vocabulary the surface asks in, and a script that could not see the
	// number the form shows would be a second answer to the same question.
	Size int `json:"size"`
	//
	// **A boolean rather than the image, and there is no endpoint that returns
	// the bytes.** The two operations this milestone adds are set and clear; what
	// a stored logo is *for* is M50.6, which composites it into the picture the
	// existing `.svg` and `.png` paths already serve. Until then the only thing a
	// client needs to know is whether its upload landed and whether a clear took
	// effect, and that is one bit — which is also all the reads fetch, because
	// the bytes are a megabyte a row and a list of twenty codes must not pull
	// them to print twenty names.
	HasLogo bool `json:"has_logo"`
}

QRCode is one of a link's codes: the style it is drawn with and the URL it encodes.

type QRSizeInput added in v0.3.0

type QRSizeInput struct {
	Foreground string
	Background string
	// Size is the output size in pixels, and it is the size drawn: since D182
	// nothing snaps, because the fit puts the rounding remainder into the quiet
	// zone rather than into this number.
	Size int
}

QRSizeInput is the dashboard's write: the colours somebody chose and the one number they know, which is how big they want the picture (M49).

**The error-correction level is deliberately absent**, and its absence is what makes SetQRSize a different operation from SetQRStyle rather than a wrapper with defaults. A save from a form that no longer asks about error correction must not silently answer the question; the level a link already has is carried forward, and a caller that wants to choose one uses the API.

Since D184 what is carried forward is the **floor** — usually none at all, and then the level is the rule's. A form that wrote a level here would be pinning one for a reader who was never asked, which is the shape of the defect that reopened this milestone.

type QRSizeRise added in v0.3.0

type QRSizeRise struct {
	// From is the size the row carried and To is the size it now carries.
	From, To int
}

QRSizeRise is a re-fit that had to push a stored size **up**, and it is the only re-fit anybody is told about (M49's third reopening, D185).

Owner-set, in the answer that reopened the milestone: *"The user doesn't need to be notified unless we need to raise the currently selected size."* A re-fit that keeps the number the reader chose is a scale change they cannot see and a picture that measures what it always did, so a sentence about it is a sentence that teaches readers to stop reading them. A re-fit that cannot keep it has changed a number somebody typed, and that is not the product's to do quietly.

The zero value is *nothing happened*, which is the common case: QRSizeRise.Rose is what every caller branches on.

func (QRSizeRise) Rose added in v0.3.0

func (r QRSizeRise) Rose() bool

Rose reports whether the size the reader chose had to be raised.

type RootInvalidator

type RootInvalidator interface {
	InvalidateRoot()
}

RootInvalidator drops the cached root redirect when it changes. Nil is valid and means the redirect tree is not running in this process.

type Service

type Service struct {
	// contains filtered or unexported fields
}

func NewService

func NewService(pool *pgxpool.Pool, cfg Config) *Service

func (*Service) Archive

func (s *Service) Archive(ctx context.Context, actor *auth.Identity, id uuid.UUID) (*domain.Link, error)

func (*Service) ArchiveByRule added in v0.2.0

func (s *Service) ArchiveByRule(
	ctx context.Context, workspaceID, linkID uuid.UUID,
) (bool, error)

ArchiveByRule archives a link because a rule said so.

**No actor, and that is the whole difference from Archive.** The interactive path authorizes against a signed-in identity holding `links.delete`; this one is authorized by the rule, which somebody holding `automation.write` created. Building a synthetic identity that holds `links.delete` would have been the other way to do it, and it is worse: `auth.Identity`'s permission set is private precisely so nothing outside internal/auth can mint authority, and a scheduler that manufactures a principal is a scheduler whose reach nobody can audit by reading the role map.

It emits `link.archived` like every other archive, so a webhook receiver reconciling state sees the same event whether a person or a rule moved the link. It does **not** touch `expires_at`, which the statement says and which the trigger vocabulary depends on.

Returns whether the link was still active. An already-archived link is not an error — a rule whose window overlapped an interactive archive should not fail its whole firing over it — but it is not counted as work either.

func (*Service) AutomationRule added in v0.2.0

func (s *Service) AutomationRule(
	ctx context.Context, actor *auth.Identity, id uuid.UUID,
) (*domain.AutomationRule, error)

AutomationRule reads one rule.

func (*Service) AutomationRules added in v0.2.0

func (s *Service) AutomationRules(ctx context.Context, actor *auth.Identity) ([]domain.AutomationRule, error)

AutomationRules lists a workspace's rules.

func (*Service) Campaign added in v0.2.0

func (s *Service) Campaign(
	ctx context.Context, actor *auth.Identity, id uuid.UUID,
) (*domain.Campaign, error)

Campaign returns one campaign.

func (*Service) Campaigns added in v0.2.0

func (s *Service) Campaigns(ctx context.Context, actor *auth.Identity) ([]domain.Campaign, error)

Campaigns returns the workspace's campaigns, each with its link count.

func (s *Service) ClearQRCodeLogo(
	ctx context.Context, actor *auth.Identity, linkID uuid.UUID, slug string,
) error

ClearQRCodeLogo removes the image from one of a link's codes. The empty slug is the link's default code.

**The artefact goes, not just the reference**, which under D134 is one and the same write: the bytes are the column. Idempotent, and it does not care whether there was a logo — "this code has no logo" is already true for a code that never had one, and reporting 404 for it would make the operation care whether a preference had ever been expressed, which is the trade ResetQRStyle already makes.

**No row is that same case and not a 404**, for the default code only. A default code nobody has styled or uploaded to has no row, so it has no logo, so the clear has already happened. A named code with no row does not exist, and that is still a 404 — which is what stops this operation being a way to ask whether a slug was ever issued.

**And the level H the upload forced goes with the image** (F223, D184). It was left where it was, on the reasoning that a picture may already be printed and H is the safer of the two to be left at — which the owner overruled in as many words: *"the old QR should still resolve as long as the link stays the same, so a change in the new code shouldn't be an issue."* The payload is untouched, so every printed picture goes on resolving; what H costs is ~30% more modules a side than the code needs, on a code with nothing left covering it.

**The bytes and the style leave in one statement**, which is the whole of why the clear takes a style at all. A style write here would be an upsert, and an upsert racing a `DeleteQRCode` finds nothing to conflict with and **inserts a fresh row** — the code the reader deleted, back with its slug, because a removal wrote to it. One `UPDATE` on the id cannot: a row that is gone updates nothing.

func (*Service) Create

func (s *Service) Create(ctx context.Context, actor *auth.Identity, in CreateInput) (*domain.Link, error)

Create makes a link, generating an alias when none is supplied.

func (*Service) CreateAutomationRule added in v0.2.0

func (s *Service) CreateAutomationRule(
	ctx context.Context, actor *auth.Identity, in CreateAutomationRuleInput,
) (*domain.AutomationRule, error)

CreateAutomationRule writes a rule and arms it.

**Armed at creation, not at first firing.** The watermark is set to now, so the rule acts on what happens after it exists. A NULL watermark would mean "everything that ever happened", and the first run of a rule somebody created this afternoon would archive every link that expired last year.

func (*Service) CreateCampaign added in v0.2.0

func (s *Service) CreateCampaign(
	ctx context.Context, actor *auth.Identity, in CreateCampaignInput,
) (*domain.Campaign, error)

CreateCampaign adds a campaign.

func (*Service) CreateFolder added in v0.2.0

func (s *Service) CreateFolder(
	ctx context.Context, actor *auth.Identity, in CreateFolderInput,
) (*domain.Folder, error)

CreateFolder adds a folder, optionally inside another.

func (*Service) CreateQRCode added in v0.3.0

func (s *Service) CreateQRCode(
	ctx context.Context, actor *auth.Identity, linkID uuid.UUID, label string,
) (*QRCode, QRSizeRise, error)

CreateQRCode adds a named code to a link.

A new code starts at the link's *default* style rather than at the product default, because somebody adding a second poster wants the poster they already have. Nothing is copied that identifies the code it was copied from: the slug is new, the label is what the caller asked for, and the two codes are thereafter independent.

**Both rows are re-fitted here, and the second return says when that cost the reader a number** (M49's third reopening, F225, F226, D185). This is the one operation that lengthens a payload: it gives the default a slug, and it copies a style fitted against the untagged picture onto a code whose picture carries a tag. Either half leaves a `size` the symbol has outgrown, which the drawing answers by falling back to margin-and-scale and measuring something else. See refitForPayload for what is kept and what is reported.

**The link's default code gets its row here, and this is the moment it has to** (D183). Until this reopening a link could carry a named row and a default that was synthesised on every read, which is the state the owner reported: two codes in the list, and the first with nothing to remove. A code with no row has no slug, no flag and nothing to delete, so the second code is not added until the first one exists — 04400 did the same for the links already in that state, and this keeps new ones out of it.

func (*Service) CreateRule added in v0.2.0

func (s *Service) CreateRule(
	ctx context.Context, actor *auth.Identity, linkID uuid.UUID, in CreateRuleInput,
) (*domain.RoutingRule, error)

CreateRule adds a rule to a link.

Two rows in one transaction: the destination the rule points at, and the rule itself. Separately committed, a failure between them leaves either a rule with no target — which the CHECK added in migration 02000 refuses outright — or a destination nothing references, which is an orphan nobody can see or delete.

func (*Service) CreateVariant added in v0.2.0

func (s *Service) CreateVariant(
	ctx context.Context, actor *auth.Identity, linkID uuid.UUID, in CreateVariantInput,
) (*domain.Variant, error)

CreateVariant adds an arm to a link's split.

Two rows in one transaction, for the reason CreateRule states: separately committed, a failure between them leaves either a rule the CHECK refuses or a destination nothing references.

func (*Service) CreateWebhook added in v0.2.0

func (s *Service) CreateWebhook(
	ctx context.Context, actor *auth.Identity, in CreateWebhookInput,
) (*domain.Webhook, error)

CreateWebhook registers a URL and mints its signing secret.

The secret is returned exactly once, in the value this call produces. Nothing reads it back afterwards — ListWebhooks and GetWebhook do not select the column — so a receiver that loses it rotates rather than looks it up.

func (*Service) Delete

func (s *Service) Delete(ctx context.Context, actor *auth.Identity, id uuid.UUID) error

Delete soft-deletes a link, keeping it restorable for TrashRetentionDays.

func (*Service) DeleteAutomationRule added in v0.2.0

func (s *Service) DeleteAutomationRule(ctx context.Context, actor *auth.Identity, id uuid.UUID) error

DeleteAutomationRule removes a rule.

func (*Service) DeleteCampaign added in v0.2.0

func (s *Service) DeleteCampaign(ctx context.Context, actor *auth.Identity, id uuid.UUID) error

DeleteCampaign removes a campaign and unlabels its links.

**The links survive**, which is the whole of what this method has to get right. A campaign is a label, and deleting a label deletes no link — but unlike the folder case (02400), no foreign key does this: the delete is soft, so `links.campaign_id ON DELETE SET NULL` never fires. Both statements are therefore in one transaction, and test/integration/campaigns_test.go asserts it rather than assuming it.

func (*Service) DeleteDomain added in v0.2.0

func (s *Service) DeleteDomain(ctx context.Context, actor *auth.Identity, id uuid.UUID) error

DeleteDomain removes a registered hostname.

Soft, unlike a folder, and the difference is what the row is. A folder holds nothing but a name; a domain is the namespace its links' aliases live in, and `links.domain_id` is NOT NULL with no cascade — every click event and every reserved alias still points at the row. So the row stays and stops being listed.

Refused while any link is on it. Zero links is the only state a registered hostname can be in today, because nothing serves one; the guard exists so that it is already true when M40 makes the state reachable, rather than being remembered then.

func (*Service) DeleteFolder added in v0.2.0

func (s *Service) DeleteFolder(ctx context.Context, actor *auth.Identity, id uuid.UUID) error

DeleteFolder removes a folder and the folders inside it.

**The links survive.** `links.folder_id` has been `ON DELETE SET NULL` since 00300 and `folders.parent_id` has been `ON DELETE CASCADE` for as long, so one DELETE removes the branch and unfiles every link anywhere in it. Nothing in this method touches `links`, and nothing in it may: the moment deleting a container starts deleting content, somebody loses a campaign's worth of links by tidying up a tree. test/integration/folders_test.go is where that is asserted rather than assumed.

func (*Service) DeleteQRCode added in v0.3.0

func (s *Service) DeleteQRCode(
	ctx context.Context, actor *auth.Identity, linkID uuid.UUID, slug string,
) (promoted *QRCode, err error)

DeleteQRCode removes one of a link's codes, and reports which code was promoted if the one removed held the default flag.

**Any code can go, and the last one cannot** (D183). It used to be the default code that could not go, because the default *was* the row with no slug and deleting it would have left every already-printed picture resolving to nothing. The owner rejected that: *"As long as there are multiple QR codes any of them should be able to be removed"* (F222). What replaces it is arithmetic rather than identity — a link always has a code, so the refusal falls on whichever one is the last, and what the caller almost certainly means by removing it is ResetQRStyleBySlug.

**The arithmetic counts codes rather than rows**, which is the same count the list on the page shows and the same one CreateQRCode checks the cap against. The two differ on exactly one shape — a link holding a named row whose default has no row of its own — and counting rows there would put a Remove control on two codes and refuse both.

**Removing the flag-holder promotes the oldest code that is left**, and the promotion is returned rather than performed silently, because it moves where every untagged picture of this link lands. Which code to promote is a decision and not a detail — oldest, first-in-list and the one the reader was looking at were all defensible. Oldest wins because it is the only one that is a property of the *data*: "first in list" is the same rule wearing a presentation's name, since the list orders by `created_at, id` once the flag-holder is out of it, and "the one the reader was looking at" cannot be expressed by a caller that is not a browser, so the API and the dashboard would promote different codes from the same delete. It is also the most conservative reading of what the flag is for: the longest-lived code is the one most likely to have pictures of it in the world, and the flag is what those pictures resolve through.

A deleted code's scans stop accumulating; they are not reassigned. A payload naming a slug that no longer exists is recorded as no code at all, which the analytics attribute to whichever code holds the flag, and the rows the deleted code already earned stay exactly where they are under its slug.

The whole of it is one transaction, because a delete that promoted nothing would leave a link with codes and no default — a state the read path answers by inventing a code the link does not have.

func (*Service) DeleteRule added in v0.2.0

func (s *Service) DeleteRule(ctx context.Context, actor *auth.Identity, linkID, ruleID uuid.UUID) error

DeleteRule removes a rule and the destination it pointed at.

func (*Service) DeleteTag

func (s *Service) DeleteTag(ctx context.Context, actor *auth.Identity, id uuid.UUID) error

func (*Service) DeleteVariant added in v0.2.0

func (s *Service) DeleteVariant(ctx context.Context, actor *auth.Identity, linkID, variantID uuid.UUID) error

DeleteVariant removes an arm and the destination it pointed at.

The clicks already attributed to that destination stay in click_events with an id that no longer resolves, and the breakdown reports them as a destination that no longer exists. That is deliberate: silently dropping them would make a running test's totals change when somebody tidied up.

func (*Service) DeleteWebhook added in v0.2.0

func (s *Service) DeleteWebhook(ctx context.Context, actor *auth.Identity, id uuid.UUID) error

DeleteWebhook removes a registration and every delivery it recorded.

func (*Service) DestinationDisclosure added in v0.2.0

func (s *Service) DestinationDisclosure(
	ctx context.Context, actor *auth.Identity,
) (DestinationDisclosure, error)

DestinationDisclosure answers, for this actor's workspace, what leaves.

The feed half reads the service's own checker rather than the configuration the checker was built from, so the page cannot describe a feed the service is not using. The webhook half is one indexed count — it has to be a query, because a registration is a row somebody wrote and not a process-wide setting, and the two callers are a dashboard page and a JSON GET. Neither is the redirect path, which reaches none of this: nothing here is on the hot path and the cost is one round trip on a page that already makes several.

**It returns an error rather than a partial answer.** A disclosure assembled from a failed read would report `Receiving: false`, which is the green panel — so the page fails instead, and says nothing rather than something reassuring and unchecked.

func (*Service) DomainSettings

func (s *Service) DomainSettings(ctx context.Context, actor *auth.Identity) (*DomainSettings, error)

DomainSettings reads the link domain's settings.

Readable by anyone who can read links: it is one URL an operator chose, and every visitor to the bare domain sees where it points anyway.

func (*Service) Domains added in v0.2.0

func (s *Service) Domains(ctx context.Context, actor *auth.Identity) ([]Domain, error)

Domains lists the hostnames this workspace may use.

Readable by anyone who can read links, like DomainSettings and for the same reason: the hostname a link is served on is printed beside every link in the product already. Managing one is what needs `domains.write`, and the `Manageable` flag on each row says which of them this actor may.

func (*Service) Folders added in v0.2.0

func (s *Service) Folders(ctx context.Context, actor *auth.Identity) (domain.FolderTree, error)

Folders returns the workspace's folder tree.

func (*Service) ForgetHostnames added in v0.2.0

func (s *Service) ForgetHostnames()

ForgetHostnames drops the id-to-hostname cache.

Wired to the same signal that reloads the verified-hostname set, so a rename on one replica reaches the short URLs printed by every other one. Without it a renamed domain would keep being advertised under its old name until the process restarted — a stale string in the one field whose whole job is to be copied and pasted.

func (*Service) Get

func (s *Service) Get(ctx context.Context, actor *auth.Identity, id uuid.UUID) (*domain.Link, error)

func (*Service) GetSplit added in v0.2.0

func (s *Service) GetSplit(ctx context.Context, actor *auth.Identity, linkID uuid.UUID) (*domain.Split, error)

GetSplit returns a link's whole split test.

func (*Service) Judge added in v0.2.0

func (s *Service) Judge(ctx context.Context, raw string) (Verdict, error)

Judge runs every tier against a destination and reports what they make of it.

It is the single call site of ValidateDestination in the whole program, and that is enforced by test rather than by discipline. The plan review found this bypass in two of three candidate orderings: a later milestone adds a surface that writes a destination, calls the validator directly because that is what the existing code appears to do, and inherits the SSRF refusals while silently skipping every tier above them. Having one door removes the choice.

Its own callers are policed too, by the same test, because a caller reaching past checkDestination to here would get the verdict without the audit record. That is legitimate for a dispute, which is arguing about a refusal already on record, and is a silent gap for anything that writes a destination.

func (*Service) LinkDomainBots added in v0.2.0

func (s *Service) LinkDomainBots(
	ctx context.Context, actor *auth.Identity, linkID uuid.UUID,
) (*LinkDomainBots, error)

LinkDomainBots reads the bot policy that applies to one link.

**The link's own domain, which is not always the instance default (F89).** The link detail page read `DomainSettings` for every link, which is the default domain's row whatever hostname the link is served on: on a link served from a verified custom hostname (M40) the page disabled a control the API would have accepted and named the wrong hostname in the sentence explaining why. The API's own guard has always read the right row — `Update` asks `GetDomainBotSettings(existing.DomainID)` before refusing an `off` the domain enforces — so this is the page being brought to where the API already was, and m32.5.md's "asserted by test at both surfaces" becomes true again.

Guarded by links.read and scoped to the actor's workspace, because a hostname is a thing worth not leaking: reading it through a link id must not answer for a link the caller cannot see.

func (*Service) List

List returns a keyset-paginated page of links.

func (*Service) ListQRCodes added in v0.3.0

func (s *Service) ListQRCodes(
	ctx context.Context, actor *auth.Identity, linkID uuid.UUID,
) ([]QRCode, error)

ListQRCodes returns every code a link carries, in alphabetical order by name.

**The order is the query's and it is alphabetical since M50.8**, not default-first: `ORDER BY lower(q.label), q.id`, which campaigns.sql argues where it is written. What changed here is the consequence — this function may no longer read position 0 as the default, and did.

**The default is synthesised when no row holds it**, for the same reason QRCodeBySlug answers for it: the link has that code whether or not anybody has styled it, and a list that omitted it would show a link's second code as its only one. A link nobody has touched therefore lists exactly one code, which is the state every link is in until this milestone's create operation is used.

**The test for synthesising is the flag, with the empty slug behind it** (D183). It read `rows[0].Slug != ""`, which was the same question while the default was the empty slug; it is now the flag, falling back to that slug for the reason GetDefaultQRCode falls back to it — a row can still arrive carrying the old spelling and not the new one.

**Every row is asked, not the first one** (M50.8). While the list led with the flag-holder, asking `rows[0]` was asking the whole set — the one row that could hold the flag was the one that would be first. Alphabetical order breaks that identity, and left alone the test would have invented a second default for every link whose flag-holder does not sort first, then counted every untagged scan against both. The synthesised code carries no label, so prepending it keeps the list alphabetical.

func (*Service) ListRules added in v0.2.0

func (s *Service) ListRules(ctx context.Context, actor *auth.Identity, linkID uuid.UUID) ([]domain.RoutingRule, error)

ListRules returns a link's rules in the order the redirect path evaluates them.

func (*Service) ListTags

func (s *Service) ListTags(ctx context.Context, actor *auth.Identity) ([]domain.Tag, error)

func (*Service) LoadRootRedirect

func (s *Service) LoadRootRedirect(ctx context.Context) (string, error)

LoadRootRedirect reads the current value for the redirect path. Unexported callers only: the hot path uses it through a cache.

func (*Service) MoveFolder added in v0.2.0

func (s *Service) MoveFolder(
	ctx context.Context, actor *auth.Identity, id uuid.UUID, parentID *uuid.UUID,
) (*domain.Folder, error)

MoveFolder re-parents a folder, subtree and all. A nil parent moves it to the top level.

**This is where the cycle rule bites.** Everything else in this file is a name and a number; a move is the one operation that can make the tree stop being one, and the check is against the tree as it is now rather than against an assumption about how it got that way.

func (*Service) QRCode added in v0.2.0

func (s *Service) QRCode(
	ctx context.Context, actor *auth.Identity, linkID uuid.UUID,
) (*QRCode, error)

QRCode returns a link's default code: its content and the style it is drawn with.

The shorthand, unchanged in meaning since M41: it answers for the code whose payload carries no code parameter, which is the one on every picture this product has ever produced. QRCodeBySlug is how a named code is reached.

func (*Service) QRCodeBySlug added in v0.3.0

func (s *Service) QRCodeBySlug(
	ctx context.Context, actor *auth.Identity, linkID uuid.UUID, slug string,
) (*QRCode, error)

QRCodeBySlug returns one of a link's codes.

The empty slug asks for the default code and never 404s: a link that has never been styled or named still has one, drawn at the default style, which is what "a QR endpoint returns a code for any link" has meant since M41. Any other slug is a row that must exist, and its absence is a 404 rather than a default — a code somebody deleted must stop answering, or a printed identity would go on resolving after the workspace retired it.

**A link's default code can be reached two ways now and they agree** (D183): by the empty string, which dispatches on the flag, and by the slug the flag's holder carries. The second is what the codes list links to and what the per-code API paths address, and it is why nothing in this function special- cases which of the two it was given.

func (s *Service) QRCodeLogo(
	ctx context.Context, actor *auth.Identity, linkID uuid.UUID, slug string,
) ([]byte, error)

QRCodeLogo returns the image stored against one of a link's codes, or nil.

**`links.read`, the same permission that renders a code**, because that is what this is for: the dashboard draws its own SVG rather than fetching the API endpoint, so it needs the same input the endpoint's renderer has. Nothing in the API document exposes it — the two operations on a logo are still replace and remove.

Nil rather than an error for a code with no logo, and for a default code with no row: "there is nothing to draw" is the answer in both cases, and it is the answer a clear that raced this read should also produce.

func (*Service) RegisterDomain added in v0.2.0

func (s *Service) RegisterDomain(
	ctx context.Context, actor *auth.Identity, rawHostname string,
) (*Domain, error)

RegisterDomain records a hostname as belonging to the actor's workspace.

Both owner columns are written, from the one resolved identity: the workspace because that is who owns it, and the organization because the workspace implies it and the CHECK requires the pair. Reading the organization off the actor rather than looking it up again is what makes the pair consistent — they come from the same membership resolution.

The hostname is stored **unverified**. Nothing checks whether the person registering it controls the name, and nothing here pretends to: that is what M40's verification is, and a hostname registered here is not a routing target until it happens.

**Bounded per workspace** (M40, reopened). Registration is the cheapest thing on this surface and the most expensive one downstream: every row it writes becomes a recurring DNS lookup the instance owes, so an unbounded surface let one workspace decide how much re-verification everybody else got. See domain.MaxDomainsPerWorkspace for the number and what it is bounding.

func (*Service) RenameDomain added in v0.2.0

func (s *Service) RenameDomain(
	ctx context.Context, actor *auth.Identity, id uuid.UUID, rawHostname string,
) (*Domain, error)

RenameDomain changes a registered hostname.

The hostname is the only field a registration has, and it is changeable only because nothing serves it yet — the row's aliases, click history and reserved aliases all hang off `domain_id` and are untouched by the name. Once M40 verifies a hostname, a rename has to invalidate that verification, and the bullet that says so belongs to M40 rather than being written here against behaviour that does not exist. See decisions.md, D69.

func (*Service) RenameFolder added in v0.2.0

func (s *Service) RenameFolder(
	ctx context.Context, actor *auth.Identity, id uuid.UUID, newName string,
) (*domain.Folder, error)

RenameFolder changes a folder's name, leaving it where it is.

func (*Service) RenderQR added in v0.2.0

func (s *Service) RenderQR(
	ctx context.Context, actor *auth.Identity, linkID uuid.UUID,
) ([]byte, error)

RenderQR draws a link's default code as SVG.

func (*Service) RenderQRBySlug added in v0.3.0

func (s *Service) RenderQRBySlug(
	ctx context.Context, actor *auth.Identity, linkID uuid.UUID, slug string,
) ([]byte, error)

RenderQRBySlug draws one of a link's codes as SVG.

func (*Service) RenderQRPNG added in v0.3.0

func (s *Service) RenderQRPNG(
	ctx context.Context, actor *auth.Identity, linkID uuid.UUID,
) ([]byte, error)

RenderQRPNG rasterises a link's code (M49).

**The one refusal that is the caller's fault is the size.** A style written before M49 carries whatever margin and scale it was given, up to 16 and 32, and a long URL at those settings describes a picture past qr.MaxSize. That is a 422 rather than a 500: the reader can make it smaller, and the alternative — rasterising it anyway — is the unbounded allocation D11 refused to allow in the first place. Everything else reaching the error path here is the product's own mistake, exactly as it is for RenderQR.

func (*Service) RenderQRPNGBySlug added in v0.3.0

func (s *Service) RenderQRPNGBySlug(
	ctx context.Context, actor *auth.Identity, linkID uuid.UUID, slug string,
) ([]byte, error)

RenderQRPNGBySlug rasterises one of a link's codes.

func (*Service) ResetQRStyle added in v0.2.0

func (s *Service) ResetQRStyle(ctx context.Context, actor *auth.Identity, linkID uuid.UUID) error

ResetQRStyle returns a link's default code to the default style.

func (*Service) ResetQRStyleBySlug added in v0.3.0

func (s *Service) ResetQRStyleBySlug(
	ctx context.Context, actor *auth.Identity, linkID uuid.UUID, slug string,
) error

ResetQRStyleBySlug returns one of a link's codes to the default style.

**It used to take no slug at all, and that was the second half of F222.** Pressing *Restore defaults* while a named code was selected cleared the *default* code's style — a control on a form about one code writing to another, and then dropping the reader onto the code it had written to. D183 scopes it to the selection.

**It writes the default style rather than deleting the row**, which is the other thing D183 changed here. Deleting was honest while a row held nothing but the preference being withdrawn; the row now holds the code's identity — its slug, which is printed, and the flag that says untagged scans resolve through it — and dropping those to clear two colours and a size would retire a printed identity to undo a styling. A named code has never been resettable by deletion for the same reason, and this is what it means for one.

The logo stays. It is not part of the style: *Remove the logo* is its own control with its own sentence, and a button labelled *Restore defaults* that silently discarded an uploaded image would be doing something nobody could read off it.

No error for a link whose code has no row. "Draw this at the default style" is already true, and reporting 404 for it would make the operation care whether a preference had ever been expressed — and materialising a row to write the style that row would have been read as anyway is a write for nothing.

func (*Service) Restore

func (s *Service) Restore(ctx context.Context, actor *auth.Identity, id uuid.UUID) (*domain.Link, error)

func (*Service) ReverifyDomains added in v0.2.0

func (s *Service) ReverifyDomains(ctx context.Context, now time.Time, batch int32) (DomainCheckSummary, error)

ReverifyDomains is the cadence half of D70, run by the leader.

Three outcomes per domain and the third is the one the milestone is about:

  • The check passes. The domain is verified — which is also how a hostname registered an hour ago starts serving without anybody pressing a button — and any failing streak is cleared.
  • The check fails and the window has not elapsed. The failure is recorded, the workspace is notified the *first* time, and **serving continues**. A poll against somebody else's nameserver is weak evidence; building an outage trigger out of one failed query is how an availability feature becomes an availability incident.
  • The check fails and the window has elapsed. Serving **stops**: `verified_at` is cleared, the hostname goes back to ops-only 404, and the change is broadcast so no replica keeps serving a domain this one has just unverified. This is a stop and not an escalation — a grace period whose expiry issues another warning is the silent persistence this milestone forbids, reached by a gentler route.

Which hostnames a pass reaches, and in what order, is verificationWorkList's — and it is a security property rather than a scheduling preference, because the third outcome above is the only one this instance ever performs on its own.

Errors are collected rather than returned on the first one: a nameserver that is refusing queries for one customer must not stop the other customers' domains being checked.

func (*Service) RotateWebhookSecret added in v0.2.0

func (s *Service) RotateWebhookSecret(
	ctx context.Context, actor *auth.Identity, id uuid.UUID,
) (*domain.Webhook, error)

RotateWebhookSecret mints a new signing secret and returns it once.

There is no overlap window, and that is deliberate: two valid secrets at once would mean a receiver that has been compromised keeps verifying for as long as the window lasts, which is the opposite of what somebody rotating a leaked secret wants. The cost is that a receiver must be updated promptly, and the audit action exists so "our verification broke at 14:03" is findable.

func (*Service) SeedBlocklist added in v0.2.0

func (s *Service) SeedBlocklist(ctx context.Context, hosts []string) error

SeedBlocklist reconciles LINKCTRL_DESTINATION_BLOCKLIST into the runtime list.

Run at boot. The environment variable seeds the Postgres list and keeps feeding it, so an operator who has been using it keeps the behaviour they had — the entries simply arrive as rows, gaining a reason code, an audit trail and a way for the owner to see them.

Environment entries are reconciled, not merely inserted: a host the operator has since removed from the variable is deleted on the next boot, or the variable would be a one-way ratchet whose entries could only ever be undone with SQL. The delete is scoped to SourceEnv and reaches no other source — not what the review queue added, and not the seeded shorteners — because a restart quietly reversing a decision somebody made is the one failure this reconciliation must not have. That scoping is the whole job of the source column, and it is why the seed has one of its own rather than borrowing 'env'. Entries are folded through canonicalHost, the same fold a destination gets, so that "münchen.example" in the variable and a link to https://münchen.example/ are the same string by the time the database compares them. An entry that cannot be folded is a hard error rather than a row nothing will ever match: the caller in cmd/linkctrl treats a seeding failure as fatal and says why — an instance whose refusals do not match its configuration is worse than one that does not start — and a line an operator added and which silently refuses nothing is exactly that state. **An entry that is not a bare hostname is stored and matches nothing** (F58).

`canonicalHost` folds and validates the *shape* of a host, so `https://evil.example` and `*.evil.example` survive it, land in the table, and appear in the operator's list — while `MatchBlockedDestination` is asked only for dot-delimited fragments of a real hostname, which neither can ever be. The operator sees their entry and it refuses nothing.

`checkListEntry` exists three hundred lines up and would catch it, and calling it here is **wrong**: it refuses IP-literal entries, which work today and which an operator blocking `169.254.169.254` reasonably expects to. So the cheap fix trades a silent no-op for a silent regression, which is why this is recorded rather than repaired. It is also not a regression from anything: the pre-M30 in-memory list validated nothing either, and m30.md's only claim here is that the env list keeps working.

Self-inflicted and bounded — an operator's own typo cannot make the instance refuse *less* than it did before M30 — but it is one function call away from the boot-time rigour `main.go` spends on exactly this class of mistake.

func (*Service) SetBotBlocking added in v0.2.0

func (s *Service) SetBotBlocking(ctx context.Context, actor *auth.Identity, block, enforced bool) (*DomainSettings, error)

SetBotBlocking turns bot blocking on or off for every link on the instance, and decides whether a link may overrule it.

Guarded by domains.write rather than links.update, and the reason is the same one the root redirect has: one hostname serves every workspace on this instance, so this is not a setting about some links. Enforcing it decides for all of them, including links whose owners deliberately turned blocking off.

**It reaches every domain row, not only the default (F89).** The redirect path reads the policy from the link's own domain — `ResolveAliasForRedirect` joins on `l.domain_id` — so while this wrote only the `is_default` row, a link served on a verified custom hostname (M40) was never blocked whatever the operator set, and D71 makes a workspace's own hostname the default for its new links, so the hole opened without anybody choosing it. `SetBotBlockingForEveryDomain` is the writer and `CreateDomain` inherits the current answer, which together are what the word *instance-wide* has always claimed.

Widening the setter to take a domain id instead was the other way to close it and it is the wrong one: the guard here is `domains.write`, which F70 records as reaching every organization's owner and admin on a multi-organization instance, so a per-hostname policy would let a workspace switch off an enforcement the operator set — a wider hole than the one being closed. Per- domain settings are D69's parked question and they need the instance-level principal D38 does not have.

Unlike the root redirect it is NOT refused on a single-host deployment. That refusal exists because "/" belongs to the dashboard there and honouring the setting would take the dashboard away; nothing of the sort applies here. Short links are served on a single-host instance exactly as on a split one, and so is the crawler traffic this refuses.

The cost of switching this on is worth stating where the operator's own documentation will repeat it: analytics.Classify decides who is a bot, it matches substrings including "preview", "monitor" and "checker", it treats an absent user agent as automated, and its false-positive rate has never been measured because until this milestone nothing depended on it. A person it misclassifies gets a 403 and has no way past it — no bypass is built and nothing schedules one, Phase 3 having declined the redirect-path area outright (D108) — and nobody tells the link's owner it happened.

func (*Service) SetDefaultQRCode added in v0.3.0

func (s *Service) SetDefaultQRCode(
	ctx context.Context, actor *auth.Identity, linkID uuid.UUID, slug string,
) (*QRCode, error)

SetDefaultQRCode makes one of a link's codes the one untagged scans resolve through (D183).

**No clearing operation beside it, because a link always has a default.** The flag is not a preference that can be withdrawn — it answers "where does a picture with no tag on it land", and that question has an answer for every link whether anybody has chosen one or not. So this moves the flag and there is nothing that removes it.

The code must already have a row, which for a named code it always does. The default's own row is written here if it has none, because moving the flag off a code that is not written down is moving it off nothing.

func (*Service) SetDomainRootRedirect added in v0.2.0

func (s *Service) SetDomainRootRedirect(
	ctx context.Context, actor *auth.Identity, id uuid.UUID, rawURL string,
) (*Domain, error)

SetDomainRootRedirect points one registered hostname's root somewhere.

The per-domain half of a setting that has existed since 00800 for the instance default. A custom hostname is a bare domain somebody will type into a browser, and whether that answers 404 or goes to the workspace's own site is the workspace's choice rather than the operator's.

Three things are deliberately not shared with the instance-default version. It is **not** refused on a single-host deployment: the custom hostname is not the dashboard's host whatever LINK_BASE_URL says, so its root belongs to nobody else. It is guarded by the **ownership** check rather than by bare `domains.write`, because this is one workspace's hostname. And it is refused on an **unverified** hostname, because nothing is served there — offering to configure where its root points would be offering a setting with no effect.

The destination goes through the same validation a link's does, which matters here for the reason it matters on the instance root: a redirect reachable with no alias and no link is the cleanest SSRF this product could offer.

func (*Service) SetQRCodeLabel added in v0.3.0

func (s *Service) SetQRCodeLabel(
	ctx context.Context, actor *auth.Identity, linkID uuid.UUID, slug, label string,
) (*QRCode, error)

SetQRCodeLabel renames one of a link's codes.

The slug is not touched and cannot be: it is printed. A rename is a change to what the dashboard calls a code, never to what the code says.

func (s *Service) SetQRCodeLogo(
	ctx context.Context, actor *auth.Identity, linkID uuid.UUID, slug string, upload []byte,
) (*QRCode, LogoFit, error)

SetQRCodeLogo stores an uploaded image against one of a link's codes. The empty slug asks for the link's default code, which since D183 is a flag on a row rather than the absence of a slug — see the preamble above.

Replacing is the same call: the write is a single UPDATE, so the image being replaced is overwritten rather than deleted by a second statement, and there is no state in which a code has two logos or none.

**The second return is what the F214 reopening added.** An image past the storage target is now shrunk to fit instead of refused, and a caller that was not told would report an unqualified success for a picture this product changed. It is a LogoFit rather than an error because nothing went wrong — the upload was accepted, and the sentence beside it is a warning.

func (*Service) SetQRSize added in v0.3.0

func (s *Service) SetQRSize(
	ctx context.Context, actor *auth.Identity, linkID uuid.UUID, in QRSizeInput,
) (*QRCode, qr.SizeFit, error)

SetQRSize stores a style described by its output size.

The size is resolved against *this link's* module count, because that is what decides how many pixels a scale comes to. The number is then written into the row — `qr.Style.Size`, which SetQRSizeBySlug sets below (D182) — so a link whose alias grows later goes on drawing at exactly it, until the larger symbol and its minimum quiet zone no longer fit inside that many pixels. Past that point the margin and scale stored alongside it take over and the picture draws slightly larger, which is the same behaviour every pre-M49 style has.

func (*Service) SetQRSizeBySlug added in v0.3.0

func (s *Service) SetQRSizeBySlug(
	ctx context.Context, actor *auth.Identity, linkID uuid.UUID, slug string, in QRSizeInput,
) (*QRCode, qr.SizeFit, error)

SetQRSizeBySlug stores one code's style, described by its output size.

The size is resolved against *this code's* module count rather than the link's. Two codes for one link encode different payloads — one carries a slug and the other does not — so they are different matrices, and a size fitted against the wrong one snaps to a scale that draws the picture at some other number of pixels than the one asked for.

func (*Service) SetQRStyle added in v0.2.0

func (s *Service) SetQRStyle(
	ctx context.Context, actor *auth.Identity, linkID uuid.UUID, style qr.Style,
) (*QRCode, error)

SetQRStyle stores how a link's default code is drawn.

func (*Service) SetQRStyleBySlug added in v0.3.0

func (s *Service) SetQRStyleBySlug(
	ctx context.Context, actor *auth.Identity, linkID uuid.UUID, slug string, style qr.Style,
) (*QRCode, error)

SetQRStyleBySlug stores how one of a link's codes is drawn.

A named code must already exist: styling is a change to a code, and the operation that brings one into being is CreateQRCode. The default code is the exception it has always been — its row appears the first time somebody expresses a preference about it.

func (*Service) SetRootRedirect

func (s *Service) SetRootRedirect(ctx context.Context, actor *auth.Identity, rawURL string) (*DomainSettings, error)

SetRootRedirect points the link domain's root somewhere, or clears it.

Three refusals, each of which would otherwise be discovered late.

It needs domains.write rather than links.update: this is not one link, it is where every visitor who trims a short link back to its domain ends up.

It is refused outright on a single-host deployment. There "/" is the dashboard, and honouring this would take the dashboard away from the person who just set it — a failure that reads as the product breaking rather than as a setting doing what it says.

The destination goes through exactly the same validation as a link's, which matters more here than anywhere: a root redirect that skipped the private, loopback and metadata refusals would be a cleaner SSRF than the one the validator exists to prevent, because reaching it needs no link and no alias.

func (*Service) Sign added in v0.2.0

func (s *Service) Sign(ctx context.Context, actor *auth.Identity, id uuid.UUID, ttl time.Duration) (*SignedLink, error)

Sign mints a signed URL for a link.

Guarded by links.update rather than links.read, and the choice is not cosmetic: a signature is the thing that makes a gated link followable, so issuing one is handing out access. Somebody who may only read the catalogue must not be able to mint a capability for a link they cannot otherwise open.

The workspace secret is minted on first use here, which is why this is the only place that can create one — a signature nobody asked for should not bring a key into existence.

func (*Service) Update

func (s *Service) Update(ctx context.Context, actor *auth.Identity, id uuid.UUID, in UpdateInput) (*domain.Link, error)

func (*Service) UpdateAutomationRule added in v0.2.0

func (s *Service) UpdateAutomationRule(
	ctx context.Context, actor *auth.Identity, id uuid.UUID, in UpdateAutomationRuleInput,
) (*domain.AutomationRule, error)

UpdateAutomationRule changes a rule's name, trigger, threshold, actions or switch.

The whole rule is re-validated, not only what changed: an action list that was legal against the old trigger can be illegal against the new one — `archive_link` on a rule moved to `destination.blocked` has no link to archive — and an edit that changed one field must not leave the row in a state a create would refuse.

func (*Service) UpdateCampaign added in v0.2.0

func (s *Service) UpdateCampaign(
	ctx context.Context, actor *auth.Identity, id uuid.UUID, in UpdateCampaignInput,
) (*domain.Campaign, error)

UpdateCampaign edits a campaign.

func (*Service) UpdateRule added in v0.2.0

func (s *Service) UpdateRule(
	ctx context.Context, actor *auth.Identity, linkID, ruleID uuid.UUID, in UpdateRuleInput,
) (*domain.RoutingRule, error)

UpdateRule changes a rule.

The destination is updated in place rather than replaced, so a rule's target keeps its identity across an edit. Replacing it would mean a new row every time somebody fixes a typo, and every old one an orphan.

func (*Service) UpdateVariant added in v0.2.0

func (s *Service) UpdateVariant(
	ctx context.Context, actor *auth.Identity, linkID, variantID uuid.UUID, in UpdateVariantInput,
) (*domain.Variant, error)

UpdateVariant changes an arm's destination, weight or enabled flag.

The destination is updated in place rather than replaced, so an arm keeps its identity — and therefore its clicks — across an edit. Replacing it would split a running test's history in two every time somebody fixed a typo.

func (*Service) UpdateWebhook added in v0.2.0

func (s *Service) UpdateWebhook(
	ctx context.Context, actor *auth.Identity, id uuid.UUID, in UpdateWebhookInput,
) (*domain.Webhook, error)

UpdateWebhook changes a registration's URL, subscription, label or switch.

func (*Service) VerifyDomain added in v0.2.0

func (s *Service) VerifyDomain(ctx context.Context, actor *auth.Identity, id uuid.UUID) (*Domain, error)

VerifyDomain runs the challenge check now, for one domain.

On demand as well as on a cadence, because the person who has just published a TXT record should not have to wait out an hour to find out whether they got it right — and because the failure message is the only feedback there is when they did not.

Guarded by the same ownership check every other write to a domain is: this starts serving an alias namespace on a public hostname, which is not a read.

func (*Service) Webhook added in v0.2.0

func (s *Service) Webhook(ctx context.Context, actor *auth.Identity, id uuid.UUID) (*domain.Webhook, error)

Webhook reads one registration.

func (*Service) WebhookDeliveries added in v0.2.0

func (s *Service) WebhookDeliveries(
	ctx context.Context, actor *auth.Identity, id uuid.UUID, limit int32,
) ([]domain.WebhookDelivery, error)

WebhookDeliveries reads one webhook's recent attempts.

Scoped through the webhook's workspace in the query itself, so a delivery belonging to somebody else's registration is not readable by guessing an id.

func (*Service) Webhooks added in v0.2.0

func (s *Service) Webhooks(ctx context.Context, actor *auth.Identity) ([]domain.Webhook, error)

Webhooks lists a workspace's registrations.

type SignedLink struct {
	URL       string    `json:"url"`
	ExpiresAt time.Time `json:"expires_at"`
}

SignedLink is a minted signed URL and when it stops working.

type TXTLookup added in v0.2.0

type TXTLookup interface {
	LookupTXT(ctx context.Context, name string) ([]string, error)
}

TXTLookup reads DNS TXT records.

An interface with one method, and the implementation is deliberately in another package (internal/dnsx). This package's guard test — TestThisPackageOpensNoSocketOfItsOwn — fails the build on any outbound symbol here at all, because a lookup added to "check the host resolves" would send a user's destination to a nameserver that /feeds does not name. Custom-domain verification needs DNS and does not need it *here*: this file decides when to ask, and something else does the asking.

The seam earns its place twice over. A test can say exactly what DNS answered, and the demo seeder supplies a lookup that satisfies the challenge for its own reserved `.example` hostnames — so the demo shows a verified domain by passing the check rather than by writing the column behind the checker's back.

type Tier added in v0.2.0

type Tier string

Tier is how much confidence a refusal carries, and therefore what it costs to overrule.

Two threat models wear one name here, and the tier is what tells them apart. Phase 1's refusals protect *this instance* from being used as an SSRF proxy. The other two tiers protect *visitors* from a destination hostile to them. They must not share an override switch, because the party the first protects is not the party who would be appealing: an owner who could approve 169.254.169.254 on request would have turned the review queue into the SSRF the validator exists to prevent.

const (
	// TierUnappealable is Phase 1's SSRF refusals. Nothing overrules it — no
	// configuration, no list entry, no review. There is deliberately no field,
	// flag or row anywhere in this program that turns it off, and
	// TestUnappealableTierHasNoOverrideSwitch fails if one appears.
	TierUnappealable Tier = "unappealable"

	// TierHighConfidence is the curated embedded list in blocked_hosts.txt.
	// Overruled by editing that file and rebuilding, which is the point: the
	// dangerous override is a reviewable, version-controlled change rather than
	// a click at 2am.
	TierHighConfidence Tier = "high_confidence"

	// TierLowConfidence is the heuristics and the runtime Postgres blocklist.
	// Overruled by the instance owner from M31's review queue, without a
	// rebuild, because a heuristic false-positive rate is unknown until real
	// use and a tier that guesses needs a cheap way to be wrong.
	TierLowConfidence Tier = "low_confidence"
)

func (Tier) Code added in v0.2.0

func (t Tier) Code(rule string) string

Code is the reason code a refusal carries into its 422 and its audit record.

"<tier>.<rule>", so one string answers both of the questions somebody reading a refusal has: how sure was it, and what did it match. A client that only cares whether an appeal is possible reads the part before the dot.

type UpdateAutomationRuleInput added in v0.2.0

type UpdateAutomationRuleInput struct {
	Name          *string
	Trigger       *string
	TriggerConfig *domain.AutomationTriggerConfig
	Actions       []string
	Enabled       *bool
}

UpdateAutomationRuleInput is a partial update; nil fields are left alone.

type UpdateCampaignInput added in v0.2.0

type UpdateCampaignInput struct {
	Name        *string
	Slug        *string
	Description *string
	// The schedule bounds are three-valued, exactly as a link's expiry is: nil
	// leaves the bound alone and the Clear flag removes it.
	StartsAt      *time.Time
	ClearStartsAt bool
	EndsAt        *time.Time
	ClearEndsAt   bool
}

UpdateCampaignInput is a partial update; nil fields are left unchanged.

type UpdateInput

type UpdateInput struct {
	URL          *string
	Alias        *string
	Title        *string
	Description  *string
	ExpiresAt    *time.Time
	ClearExpiry  bool
	Tags         *[]string
	ForwardQuery *bool
	ForwardPath  *bool
	// BotBlocking is the link's own answer to "refuse automated clients":
	// inherit, on, or off. Nil leaves it alone, which is what the dashboard form
	// sends when the domain enforces and the control is disabled.
	BotBlocking *domain.BotPolicy

	// Which folder the link is filed in (M38). Three states, exactly as the
	// expiry and the password have: nil leaves it where it is, an id files it
	// there, and ClearFolder takes it out of every folder. A form's "no folder"
	// option is the third, and without it a link could be filed and never
	// unfiled.
	FolderID    *uuid.UUID
	ClearFolder bool

	// Which campaign the link belongs to (M41). Three states for the reason the
	// folder above has them, and the third is the one that matters: without
	// ClearCampaign the only way out of a campaign joined by mistake would be to
	// delete the campaign, which would take every other link with it.
	CampaignID    *uuid.UUID
	ClearCampaign bool

	// The gates (M35). Two of them need three states rather than two, because
	// "leave the password alone" and "remove the password" are different
	// requests and a form that posts an empty box means the first: nobody can
	// re-type a password they cannot read, so an empty field has to be "no
	// change" or every save would clear the gate.
	//
	// Clearing is therefore explicit, exactly as ClearExpiry already is.
	Password         *string
	ClearPassword    bool
	MaxClicks        *int64
	ClearMaxClicks   bool
	OneTime          *bool
	RequireSignature *bool
}

UpdateInput is a partial update; nil fields are left unchanged.

type UpdateRuleInput added in v0.2.0

type UpdateRuleInput struct {
	URL        *string
	Priority   *int32
	Conditions *domain.RuleConditions
	Enabled    *bool
}

UpdateRuleInput is a partial update; nil fields are left alone.

type UpdateVariantInput added in v0.2.0

type UpdateVariantInput struct {
	URL     *string
	Weight  *int32
	Enabled *bool
}

UpdateVariantInput is a partial update; nil fields are left alone.

type UpdateWebhookInput added in v0.2.0

type UpdateWebhookInput struct {
	URL         *string
	Events      []string
	Description *string
	Enabled     *bool
}

UpdateWebhookInput is a partial update; nil fields are left alone.

type Verdict added in v0.2.0

type Verdict struct {
	// Normalized is the destination as it would be stored. Non-empty only when
	// nothing refused it.
	Normalized string
	// Host is the destination's folded host, or "" when the URL never got far
	// enough to have one.
	Host string
	// Block is the tiered refusal, or nil. Present for every refusal that names
	// a tier, the unappealable ones included — the validator raises those as
	// reason codes rather than as a Block, and Judge recovers the tier so that
	// "which tier refused this" is one question with one answer.
	Block *Block
	// ListedHost is the blocked_destinations row that produced the refusal, or
	// "" when no row did.
	//
	// It is not the same value as Host and the difference is the point: the
	// runtime list matches on label boundaries, so blocking "evil.example"
	// refuses "login.evil.example" and the row that refuses it is the parent.
	// Only the two list-backed rules ever set this — a homograph, credentials in
	// the URL and a feed verdict are all computed rather than held, and for those
	// there is no row to name.
	//
	// M31's queue is the consumer: it records this at filing time so that a
	// decision months later acts on the row the refusal actually matched, rather
	// than on whatever the same walk would find in a list that has since changed.
	ListedHost string
	// Errs is the refusal as whoever typed the URL receives it. Its Field is
	// unset; the surface that reports it decides which input to highlight.
	// Empty exactly when Normalized is set.
	Errs domain.ValidationErrors
}

Verdict is what the tiers make of a destination. Nothing is recorded, nothing is stored, and no field says what to do about it.

It exists because two callers need the same judgement for different reasons. A destination-writing surface needs it in order to refuse and to record the refusal. M31's dispute path needs it to answer a question no surface asks — *may this refusal be appealed at all* — and must not write a second `destination.blocked` record for a refusal that already happened, because double-counting is exactly what would ruin the numbers the log exists to let an operator tune.

type WebhookDisclosure added in v0.2.0

type WebhookDisclosure struct {
	// Receiving is true when at least one enabled registration in this workspace
	// is subscribed to an event whose payload carries a destination.
	//
	// A boolean beside a count is not redundancy. The page and any client would
	// otherwise each re-derive the predicate from `Count > 0`, and the first one
	// to get it wrong renders the green panel — so the predicate is computed once,
	// here, and published.
	Receiving bool `json:"receiving"`
	// Count is how many such registrations there are. Not how many exist: a
	// disabled one and one subscribed only to `automation.fired` receive no
	// destination and are not counted.
	Count int64 `json:"count"`
}

WebhookDisclosure is how much of a workspace's own egress it is being told about.

No URL, no name, no id. Who a workspace sends its events to is behind `webhooks.read`, and this page is behind nothing at all — what every member is owed is whether their destinations leave, not the registry of where to. The number is here because "a webhook is registered" and "four are" are different facts to somebody about to go and ask an administrator.

type WebhookEmitter added in v0.2.0

type WebhookEmitter interface {
	Emit(ctx context.Context, workspaceID uuid.UUID, event string, data map[string]any)
}

WebhookEmitter queues one event for every webhook subscribed to it.

Declared here rather than imported so internal/link never depends on internal/webhook — the same one-way shape Invalidator, FeedChecker and DomainNotifier already have. internal/webhook.Service satisfies it.

It returns nothing, and that is deliberate. Emitting is a consequence of a change that has already been committed; a link write that failed because a notification could not be queued would be a link write held hostage by an integration.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL