Documentation
¶
Overview ¶
Package domain holds the types the services exchange.
These are deliberately separate from the sqlc-generated structs. Generated types describe table shape, change whenever a column does, and carry driver concerns; domain types describe what the product means and are what handlers and templates see. The mapping between them lives in the service layer, so a column rename does not ripple into the API.
Index ¶
- Constants
- Variables
- func BlocksBots(link BotPolicy, dom DomainBotPolicy) bool
- func BotPolicyLocked(dom DomainBotPolicy) bool
- func CarriesDestination(event string) bool
- func ChallengeRecordName(hostname string) string
- func ChallengeSatisfied(token string, records []string) bool
- func ClickSource(raw string) (string, bool)
- func ClickSourceCode(slug string) string
- func IsAutomationAction(name string) bool
- func IsAutomationTrigger(name string) bool
- func IsEmptyRuleConditions(c RuleConditions) bool
- func IsVariantKind(kind string) bool
- func IsWebhookEvent(name string) bool
- func Match(c RuleConditions, s RuleSubject) bool
- func NewQRCodeSlug() string
- func NewVerificationToken() string
- func QRCodeSlugOf(value string) (string, bool)
- func SameParent(a, b *uuid.UUID) bool
- func SlugifyCampaign(s string) string
- func SortFoldersByName(folders []Folder)
- func ValidQRCodeSlug(s string) bool
- func ValidateRuleConditions(c RuleConditions) error
- type AutomationRule
- type AutomationSource
- type AutomationTriggerConfig
- type BotPolicy
- type Campaign
- type DomainBotPolicy
- type FieldError
- type Folder
- type FolderTree
- func (t FolderTree) Depth(id *uuid.UUID) int
- func (t FolderTree) Flat() []Folder
- func (t FolderTree) Get(id uuid.UUID) (Folder, bool)
- func (t FolderTree) Height(id uuid.UUID) int
- func (t FolderTree) IsAncestor(ancestor, id uuid.UUID) bool
- func (t FolderTree) Len() int
- func (t FolderTree) MoveRefusal(id uuid.UUID, parent *uuid.UUID) *FieldError
- func (t FolderTree) SiblingNamed(parent *uuid.UUID, name string) (Folder, bool)
- type Link
- type LinkFilter
- type LinkSort
- type LinkStatus
- type Page
- type RoutingRule
- type RuleConditions
- type RuleNeeds
- type RuleSubject
- type RuleTime
- type Split
- type Tag
- type ValidationErrors
- func QRCodeLabelErrors(label string) ValidationErrors
- func ValidateAutomationActions(trigger string, actions []string) ([]string, ValidationErrors)
- func ValidateAutomationRuleName(s string) ValidationErrors
- func ValidateAutomationTrigger(name string) ValidationErrors
- func ValidateAutomationTriggerConfig(c AutomationTriggerConfig) ValidationErrors
- func ValidateCampaignDescription(desc string) (string, ValidationErrors)
- func ValidateCampaignName(name string) (string, ValidationErrors)
- func ValidateCampaignSchedule(starts, ends *time.Time) ValidationErrors
- func ValidateCampaignSlug(slug, name string) (string, ValidationErrors)
- func ValidateFolderName(name string) (string, ValidationErrors)
- func ValidateHostname(raw string) (string, ValidationErrors)
- func ValidateSplitKind(kind, existing string) ValidationErrors
- func ValidateWebhookDescription(s string) ValidationErrors
- func ValidateWebhookEvents(events []string) ([]string, ValidationErrors)
- func ValidateWeight(weight int32) ValidationErrors
- type Variant
- type Webhook
- type WebhookDelivery
Constants ¶
const ( // TriggerLinkExpired fires for links whose `expires_at` has passed. // // The link is not touched by the expiry itself: an expired link keeps its // row and its status, and the redirect path answers OutcomeNotFound from the // timestamp. So this is genuinely an observation of the clock rather than of // a write, and it is the one trigger whose subject set is knowable in advance. TriggerLinkExpired = "link.expired" // TriggerLinkMaxClicks fires for links whose durable click budget ran out // (M35). The subject is `link_click_budget.exhausted_at`, which the gate // stamps in the same transaction that spends the last click — not // `links.click_count`, which is the approximate counter the analytics // pipeline writes after the fact and which 02100 says out loud must never be // an authorization input. TriggerLinkMaxClicks = "link.max_clicks" // TriggerDestinationBlocked fires when somebody in the workspace was refused // a destination (M30). The subject is the audit record, because that is the // only durable trace a refusal leaves — `blocked_destinations` is the // operator's blocklist, not a log of attempts against it. TriggerDestinationBlocked = "destination.blocked" )
The trigger vocabulary. Every value `automation_rules.trigger` may hold.
The names are the audit log's and the webhook vocabulary's where the three describe the same event, so an operator reading a rule beside a delivery beside an audit row is reading one vocabulary rather than three spellings of one.
const ( // ActionNotify writes one in-app notification per firing to the // organization's owners (M22). One per firing rather than one per subject: // a rule that matched forty expired links puts one item in an inbox saying // forty, because forty items in an inbox is how an inbox stops being read. ActionNotify = "notify" // ActionWebhook emits EventAutomationFired to the workspace's subscribed // webhooks (M42). It does not let the rule choose which event to emit, and // that restraint is the cascade guard doing its job — a rule that could emit // `destination.blocked` would be a rule that could manufacture the thing // another rule triggers on. ActionWebhook = "webhook" // ActionArchiveLink archives the matched links. Only legal on a trigger // whose subject is a link, and refused at write time otherwise rather than // silently doing nothing at evaluation time. // // **There is no `disable` action, by decision D10.** `archived` and // `disabled` produce the identical outcome on the redirect path — snapshot.go // maps both to OutcomeNotFound, deliberately, so a scanner cannot tell them // apart — and `disabled` has no restore affordance, so an automation writing // it would create links in a state the UI offers no way out of. ActionArchiveLink = "archive_link" )
The action vocabulary. Every value an entry in `automation_rules.actions` may name.
const ( // MaxAutomationRulesPerWorkspace bounds the list. Twenty, matching // MaxWebhooksPerWorkspace: a workspace wanting more standing instructions // than that wants a workflow engine. MaxAutomationRulesPerWorkspace = 20 // AutomationRulesPerRun bounds how many enabled rules one run considers, // across every workspace on the instance. Rules are taken **least recently // looked at** first — `last_checked_at`, which a run advances for every rule // it reached, whether that rule fired, matched nothing or failed — so a run // that hits this cap starves nobody: the rules it skipped keep the older // cursor and go first next time. // // Ordering on `last_fired_at` instead is what F83 was, and it is worth // naming because the two columns look interchangeable and are not. The // watermark moves only on a firing, and idle is precisely what keeps it old, // so the hundred oldest were a fixed set and the hundred-and-first enabled // rule on an instance — five workspaces at MaxAutomationRulesPerWorkspace — // was never evaluated on any run. AutomationRulesPerRun = 100 // AutomationMatchesPerRule bounds how many subjects one rule sees in one // run. A rule that matches more is truncated, logged, and its watermark // advances only to the last subject it actually handled — so the remainder // is picked up next run rather than skipped. "Last subject handled" is a // (event time, id) pair, not a timestamp: subjects tied on the boundary // timestamp are routine — bulk-created links share one expires_at — and a // timestamp-only watermark made this sentence false for exactly them, by // reopening the next window strictly after the instant the cap split. AutomationMatchesPerRule = 25 // MaxAutomationActions bounds one rule's action list. Three, because there // are three actions and repeating one is never useful — two `notify` entries // are two identical inbox items. MaxAutomationActions = 3 // MaxAutomationRuleNameLength bounds the label, in runes. MaxAutomationRuleNameLength = 120 // MaxAutomationMinCount bounds the one config key, and it is capped at the // per-run match cap rather than at a round number: a threshold larger than // one run can match is a rule that silently never fires, which is the // failure a closed vocabulary exists to prevent rather than to cause. // TestAThresholdIsAlwaysReachable holds the two together. MaxAutomationMinCount = AutomationMatchesPerRule )
**The bounds on one evaluation run, in one place.**
m43.md's Risks say trigger evaluation cost on the scheduler must be bounded, and a bound that is a product of four numbers spread over three packages is a bound nobody can state. These are all of them, and the evaluator takes its batch sizes from here rather than declaring its own.
Worst case, one run costs:
AutomationRulesPerRun x (1 match query
+ MaxAutomationActions actions
+ AutomationMatchesPerRule archive statements)
+ 1 cursor advance
which at the values below is 100 x (1 + 3 + 25) + 1 = 2,901 statements, against a one-minute clock and a two-minute job timeout. The **expected** case is 100 indexed range scans that return nothing plus that one update, because the watermark means a rule only ever looks at what happened since it last fired.
The `+ 1` is the whole cost of the fairness property below: one statement a run, whatever the batch did, rather than one per rule.
const ( AutomationInterval = time.Minute AutomationTimeout = 2 * AutomationInterval )
AutomationInterval is how often the scheduler evaluates, and AutomationTimeout bounds one run.
Here rather than in internal/automation, and the placement is load-bearing: the API and the page both advertise the interval, and a handler that had to import the evaluator to read a number would be a handler that imports the evaluator. TestNothingOnTheRequestPathImportsTheEvaluator asserts that none of them does, which is how "evaluation never runs on the request path" is enforced rather than promised.
One minute, matching the rollup's clock rather than the outbox's thirty seconds: nothing here is something a person is sitting waiting for, and a link that expired is still expired a minute later. The timeout is twice the interval, so a slow run overlaps at most one tick and a stuck one is cut off rather than holding the scheduler.
const ( MaxRuleConditionValues = 50 MaxRuleValueLength = 128 )
MaxRuleConditionValues and MaxRuleValueLength bound what one condition may hold. Both are about the snapshot rather than about the form: these bytes are serialized and parsed on every cache miss for the link that carries them.
const ( RuleKindMatch = "match" // RuleKindWeighted is one arm of a weighted split. Its share of the traffic // is its destination's `weight` over the sum of the enabled arms' weights, // which is what makes "60/40" and "600/400" the same test. RuleKindWeighted = "weighted" // RuleKindSequential is one arm of a strict rotation (D8). Arms are visited // in creation order, once each, forever, using a durable counter rather than // anything held in a process. RuleKindSequential = "sequential" // RuleKindFallback is where a link sends anybody no rule claimed. At most one // per link, and it stands in for the link's own destination without changing // it — which is what makes turning it off a reversible act rather than an // edit to the link. RuleKindFallback = "fallback" )
The four rule kinds. `match` is M34's and is the only one that reads a visitor's conditions; the other three are M36's and are chosen rather than matched.
The separation is what keeps M34's promise that shipping M36 could not retroactively change what a match rule does: every query M34 wrote filters on RuleKindMatch, every query M36 wrote excludes it, and no query reads both.
const ( // EventLinkCreated fires after a link exists, not before: a receiver that // fetches the short URL on this event must find it working. EventLinkCreated = "link.created" // EventLinkUpdated fires on any successful edit, the destination included — // and on one that resubmitted the same values, because the service does not // diff. A dashboard form posts every field on every save, so a receiver that // acts on this event should be idempotent about it rather than assume // something moved. Said out loud in docs/usage.md for the same reason. EventLinkUpdated = "link.updated" // EventLinkArchived and EventLinkRestored are the two halves of the pause // switch. Separate from `deleted` because archiving is reversible and a // receiver reconciling state needs to know which one happened. EventLinkArchived = "link.archived" EventLinkRestored = "link.restored" // EventLinkDeleted fires on the soft delete a person performs, which starts // the recovery window. Not on the purge at the end of it: that is the // scheduler tidying up thirty days later, and a receiver told "deleted" // twice for one link would double-count. EventLinkDeleted = "link.deleted" // EventDestinationBlocked is the blocked-attempt event, and it is the one // event here that is not about a link that exists. Somebody tried to point // something at a destination a tier refused; the payload names the tier, the // rule and the surface, and carries the attempted URL **defanged** exactly as // the audit record stores it. EventDestinationBlocked = "destination.blocked" // EventAutomationFired is the seventh, added by M43, and adding it is the // deliberate edit the paragraph above says it has to be. // // **It is the only event a workspace can cause this server to emit on // purpose**, and that is why an automation rule may not choose which event // its `webhook` action sends. A rule that could emit `destination.blocked` // could manufacture the thing another rule triggers on, which is the cascade // M43 is arranged to make impossible; a rule that can only emit *this* one // cannot, because nothing triggers on it. // // The payload names the rule, the trigger, how many subjects matched and the // first few of them — enough for a receiver to act, and bounded so one // firing is one message rather than a page of them. EventAutomationFired = "automation.fired" )
The event vocabulary. Every value a `webhooks.events` array may hold.
The names match the audit log's actions where the two describe the same thing (`destination.blocked` is `audit.ActionDestinationBlocked` verbatim), so an operator reading a delivery beside an audit row is reading one vocabulary rather than two spellings of one.
const ( DeliveryPending = "pending" DeliveryDelivered = "delivered" DeliveryFailed = "failed" DeliveryAbandoned = "abandoned" )
Delivery statuses, as 00600's CHECK constraint spells them.
DeliveryFailed is in the constraint and **nothing in this product writes it**. A delivery that has spent its attempts becomes `abandoned`, which says the same thing more precisely — the queue gave up, rather than one attempt failing. The constant is here so the vocabulary a reader meets in the schema is the vocabulary they meet in the code; a client should treat `failed` as it treats `abandoned` if it ever sees one.
const ChallengeLabel = "_linkctrl-challenge"
ChallengeLabel is the subdomain the TXT record is published under.
A dedicated label rather than the apex, for two reasons that both matter. The apex TXT record is shared property — SPF, DMARC and every SaaS verification anybody has ever done live there — so writing to it risks breaking something unrelated, and reading it means sifting a list. And an underscore label cannot collide with a hostname, because a hostname may not contain one; ValidateHostname refuses it, so nothing registered here can ever be the challenge name for something else.
const ClickCodeParam = "qrc"
ClickCodeParam is the query parameter a named QR code carries.
Reserved, like ClickSourceParam, and read on every redirect that carries a recognised source. Short because it is printed inside a picture: every character in the payload is another module in the matrix, and a longer parameter name makes every code that carries it physically bigger.
Not stripped before the query reaches the destination, for the reason ClickSourceParam is not: it is a label rather than a credential, and a destination whose own analytics can also see which printed code sent somebody is better informed rather than compromised.
const ClickSourceParam = "src"
ClickSourceParam is the query parameter a QR code carries. Reserved: it is read by this server on every redirect, and the values it accepts are below.
Unlike the signature parameters (M35) it is **not** stripped before the query reaches the destination. A signature is a credential and leaking one hands the destination's operator a replayable URL; a source tag is a label, and a destination whose own analytics also see that the visit came from a QR code is better informed rather than compromised.
const ClickSourceQR = "qr"
ClickSourceQR is the only value this milestone defines.
const CodeCookiesRefused = "cookies_not_supported"
CodeCookiesRefused is the reason code a cookies condition is refused with (D2).
A code rather than only a message, because this refusal is a documented product decision and not a typo: the redirect path sets no cookies and reads none, so a cookie condition would either be a lie or would make the shortener start storing a per-visitor identifier — which is the thing the whole analytics design is built to avoid. Twelve conditions ship; the thirteenth is refused by name so that nobody has to guess whether it was forgotten.
const MaxCampaignDescriptionLength = 500
MaxCampaignDescriptionLength bounds the description, in runes.
const MaxCampaignNameLength = 64
MaxCampaignNameLength bounds a campaign name, in runes. The same 64 a folder name and a tag name get: they sit in the same lists and are read the same way.
const MaxCampaignSlugLength = 48
MaxCampaignSlugLength bounds the slug. Shorter than the name, because a slug is what goes in a filter URL and a query string that wraps is one nobody copies correctly.
const MaxCampaignsPerWorkspace = 500
MaxCampaignsPerWorkspace bounds the list.
The campaigns page and every campaign `<select>` load all of them in one query, exactly as the folder tree does, so this is the number that keeps those unpaginated. A workspace wanting more than this is labelling links at a granularity tags already serve.
const MaxDestinationWeight = 10_000
MaxDestinationWeight bounds one arm's weight.
A ceiling rather than none, because the weights of a link's arms are summed on the redirect path and the sum has to stay somewhere an int32 can hold it however many arms there are. Ten thousand is four decimal places of a percentage split, which is more resolution than a test with a readable result will ever need.
const MaxDomainsPerWorkspace = 25
MaxDomainsPerWorkspace bounds how many hostnames one workspace may register.
**Unlike the campaign and folder caps, this one bounds work rather than a page.** Every registered hostname is a recurring outbound DNS lookup from whichever replica holds the leader lock, aimed at a nameserver the registrant chose, and each of those lookups can block for `DOMAIN_VERIFY_DNS_TIMEOUT` against a pass with a fixed budget. Without a bound, one workspace can decide how much of that budget exists for everybody else — and it does not even need the hostnames to resolve, which is what makes an unbounded registration surface an amplifier somebody can aim rather than a quota somebody can exceed.
**Twenty-five, and the number is a judgement.** It is bounded below by what a real tenant needs — a brand with a hostname per market, plus the ones it is migrating between — and above by the share of one pass a single workspace should be able to consume: at the default five-second timeout, twenty-five wholly unresponsive hostnames cost about two minutes of a ten-minute pass. It is deliberately not operator configuration, for the reason the campaign and folder caps are not: a number nobody has needed to raise is a constant, and making it a knob invites raising it to make a symptom go away.
Registration is bounded and never reaped. A hostname that fails every check is somebody's cut-over in progress, not an abandoned row, and nothing anywhere treats a domain's age or its unchecked state as licence to remove it.
const MaxFolderDepth = 8
MaxFolderDepth is how many levels a folder tree may have. A folder with no parent is at depth 1.
Eight, and the number comes from the two surfaces that have to render it. The tree page indents each level, so the deepest row starts a fixed distance in and must still leave room for a name and its controls; the move control is a `<select>` listing every folder in the workspace with its depth spelled out in the option label, and an option that is mostly indentation is one nobody can read. Neither breaks at eight and both are unusable well before twenty.
It is a product limit rather than a technical one. Nothing here fails at depth 40 — but a link filed nine levels down is a link nobody finds again, and a cap somebody hits is better than a tree they get lost in.
const MaxFolderNameLength = 64
MaxFolderNameLength bounds a folder name, in runes. The same 64 a tag name gets, because they sit in the same lists and are read the same way.
const MaxFoldersPerWorkspace = 500
MaxFoldersPerWorkspace bounds the whole tree.
The tree page and every folder `<select>` load all of them, so this is the number that keeps those a single small query rather than something needing pagination — and a workspace wanting more than this is asking for tags, which it already has and which are not a hierarchy.
const MaxHostnameLength = 253
MaxHostnameLength is the wire limit on a domain name, in the presentation form this product stores. RFC 1035 bounds the encoded name at 255 octets, which is 253 characters once the length prefix and the root label are taken off.
const MaxLabelLength = 63
MaxLabelLength bounds one dot-separated label, from the same RFC.
const MaxQRCodeLabelLength = 60
MaxQRCodeLabelLength bounds a label, in runes.
Short, because the label's whole job is telling one row of a list from another. Something longer than this is a description, and a code has nowhere to put one.
const MaxQRCodeSlugLength = 16
MaxQRCodeSlugLength bounds a slug on the way in and on the way out.
Checked before the snapshot is consulted, so a request carrying a megabyte of `qrc` is refused by a length test rather than by a scan over the slugs.
const MaxQRCodesPerLink = 20
MaxQRCodesPerLink bounds how many codes one link may carry.
The idiom is MaxCampaignsPerWorkspace's: the list is loaded in one query and drawn in one panel, so this is the number that keeps it unpaginated. It is also the bound on how many distinct values a link's scans can write into `link_dimension_daily` — the analytics page draws a row per code, and a link with a thousand codes is a link whose analytics page cannot be drawn.
Twenty rather than the hundreds campaigns get, because a code is a physical artefact. Each one is a picture somebody printed, mounted or published, and a workspace with twenty live print runs against a single destination has a naming problem rather than a capacity one.
const MaxRulesPerLink = 20
MaxRulesPerLink bounds a link's rule list.
A ceiling rather than no ceiling, because the list is evaluated in order on the redirect path and travels inside the cached snapshot. Twenty is well past what a rule builder is usable at and far short of what would be measurable against a 20ms budget.
const MaxVariantsPerLink = 8
MaxVariantsPerLink bounds a split.
Below MaxRulesPerLink deliberately: a link may carry match rules *and* a split, both travel in the same cached snapshot, and the two ceilings together are what bound the payload. Eight arms is past the point where a split test produces a result anybody can act on.
const MaxWebhookDeliveryPage = 50
MaxWebhookDeliveryPage bounds one read of the delivery log, on the page and on the API. A log, not a list: the interesting rows are the recent ones, and a caller wanting history has the database.
const MaxWebhookDescriptionLength = 200
MaxWebhookDescriptionLength bounds the description, in runes.
const MaxWebhooksPerWorkspace = 20
MaxWebhooksPerWorkspace bounds the list.
Every enabled webhook multiplies one link write into one queued row, so this is also the fan-out ceiling: at the maximum, creating a link writes twenty delivery rows and the scheduler makes twenty outbound connections. A workspace wanting more than that wants a fan-out service, and should be given one URL that is theirs.
const (
// QRCodeSlugLength is how many characters NewQRCodeSlug returns.
QRCodeSlugLength = 8
)
qrSlugBytes and QRCodeSlugLength are the generated slug's size.
Five bytes because base32 encodes exactly five as eight characters with no padding — the same arithmetic auth.newAPIKeyToken uses for a public id, and for the same reason: 40 bits is a handle rather than a secret, and the unique index catches the rare collision.
Eight characters is also as much as a printed payload can afford. Every character in the content is more modules in the matrix, so a longer slug is a physically larger code for every workspace that names one.
Variables ¶
var ( ErrNotFound = errors.New("not found") ErrConflict = errors.New("conflict") ErrForbidden = errors.New("forbidden") // ErrUnavailable is a dependency this process does not have, rather than a // refusal. It maps to 503: the caller did nothing wrong and asking again // somewhere else, or later, may work. ErrValidation = errors.New("validation failed") // ErrNotImplemented marks Phase 2 fields that exist in the schema and are // rejected with a clear message rather than silently ignored. Silently // accepting a field that does nothing is worse than refusing it. ErrNotImplemented = errors.New("not implemented in this version") )
Sentinel errors. Handlers map these to status codes in exactly one place, so a service can signal "not found" without knowing about HTTP.
var ActionWrites = map[string][]AutomationSource{ ActionNotify: {SourceNotification, SourceAutomationAudit}, ActionWebhook: {SourceWebhookQueue, SourceAutomationAudit}, ActionArchiveLink: {SourceLinkStatus, SourceAutomationAudit}, }
ActionWrites declares what each action produces.
Every action writes SourceAutomationAudit as well as its own effect, because a firing is recorded whatever it did. Listed on each row rather than assumed, so the disjointness test sees it.
var AutomationActions = []string{ ActionNotify, ActionWebhook, ActionArchiveLink, }
AutomationActions is the vocabulary, in the order a UI should list it: the two that tell somebody, then the one that changes something.
var AutomationTriggers = []string{ TriggerLinkExpired, TriggerLinkMaxClicks, TriggerDestinationBlocked, }
AutomationTriggers is the vocabulary, in the order a UI should list it.
Ordered rather than a bare set, for the reason WebhookEvents is: the form's radio list and the API's advertised vocabulary agree without either sorting the other's output.
var LinkSubjectTriggers = map[string]bool{ TriggerLinkExpired: true, TriggerLinkMaxClicks: true, }
LinkSubjectTriggers are the triggers whose subject is a link, and therefore the only ones ActionArchiveLink is legal on.
var RuleConditionKinds = []string{
"country", "region", "city", "language", "browser", "os", "device",
"time", "referrer", "query", "utm", "returning",
}
RuleConditionKinds are the condition names accepted in the jsonb, in the order the dashboard presents them.
var RuleWeekdays = []string{"mon", "tue", "wed", "thu", "fri", "sat", "sun"}
RuleWeekdays is the weekday vocabulary a time condition may use.
Exported because the dashboard's rule form renders the same list. Two copies of it would be a form offering a day the validator refuses, which reads as the form being broken.
var SplitKinds = []string{RuleKindWeighted, RuleKindSequential}
SplitKinds are the kinds that make a link a split test. Order is the order the dashboard offers them in.
var TriggerReads = map[string][]AutomationSource{ TriggerLinkExpired: {SourceLinkExpiry}, TriggerLinkMaxClicks: {SourceClickBudget}, TriggerDestinationBlocked: {SourceBlockedAudit}, }
TriggerReads declares what each trigger looks at.
var ( // WebhookDestinationEvents carry a destination somebody typed. The five // lifecycle events put the link's URL in `data.url` **as typed** // (link.Service.emitLink), and `destination.blocked` puts the refused // attempt in `data.url_defanged` (link.Service.emitBlocked). Defanged is // still the destination: it is reversible by anybody who wants it back. WebhookDestinationEvents = []string{ EventLinkCreated, EventLinkUpdated, EventLinkArchived, EventLinkRestored, EventLinkDeleted, EventDestinationBlocked, } )
The vocabulary split by whether an event's payload carries a destination.
**This is what the `/feeds` disclosure asks the database about**, and it is declared here rather than inside that query for the reason the vocabulary itself is declared here: the answer to *does anything in this workspace receive the destinations I submit* is a fact about what the payloads contain, and the payloads are built in internal/link. A list of event names inside a `.sql` file would be the same knowledge kept in a second place, drifting the first time an eighth event lands.
Both halves are spelled out, and neither is derived from the other, so that adding an event to WebhookEvents and forgetting to classify it fails TestEveryWebhookEventIsClassifiedForDisclosure rather than quietly reading as "carries nothing" — which is the direction the disclosure must never be wrong in.
var WebhookEvents = []string{ EventLinkCreated, EventLinkUpdated, EventLinkArchived, EventLinkRestored, EventLinkDeleted, EventDestinationBlocked, EventAutomationFired, }
WebhookEvents is the vocabulary, in the order a UI should list it.
Ordered rather than a bare set so the checkbox list and the API's advertised vocabulary agree without either sorting the other's output — the lifecycle first, in the order a link moves through it, then the refusal.
Functions ¶
func BlocksBots ¶ added in v0.2.0
func BlocksBots(link BotPolicy, dom DomainBotPolicy) bool
BlocksBots reports whether a link refuses automated clients.
All nine combinations, and the shape is worth stating because it is not symmetric: the domain wins only when it enforces. An enforcing domain overrides a link that says off — that is the entire purpose of enforcement, and it must hold for rows written before enforcement was switched on, which is why the override lives here and not only in the validation that refuses new ones.
link \ domain off on enforced inherit false true true on true true true off false false true
Unknown values in either argument fall to the safe reading — the link's is treated as inherit, the domain's as off — because the only way one arrives is a cached payload from a build that did not have this field, and refusing traffic on the strength of a value this build cannot interpret would be a worse answer than the behaviour that build already had.
func BotPolicyLocked ¶ added in v0.2.0
func BotPolicyLocked(dom DomainBotPolicy) bool
BotPolicyLocked reports whether a link's own setting is being overridden.
The API refuses an explicit BotAllow while this holds, and the dashboard disables the control, rather than accepting a value that BlocksBots would then ignore. Storing a setting that does nothing is how a link owner comes to believe they turned something off.
func CarriesDestination ¶ added in v0.2.0
CarriesDestination reports whether an event's payload contains a destination somebody submitted to this instance.
An unknown name answers false, and that is safe only because the classification is asserted total against WebhookEvents: nothing outside the vocabulary can be stored in `webhooks.events` (ValidateWebhookEvents), so a false here means "this event does not carry one" rather than "nobody has said".
func ChallengeRecordName ¶ added in v0.2.0
ChallengeRecordName is the fully-qualified name to publish the token under.
func ChallengeSatisfied ¶ added in v0.2.0
ChallengeSatisfied reports whether any of the TXT strings returned for the challenge name carries this token.
Any, not all, and not "exactly one". A zone may carry several TXT records under one name — a second LinkCtrl instance, a stale value from a previous registration, a record the resolver concatenated differently — and requiring the set to be exactly our token would fail a verification the owner has genuinely completed.
Whitespace is trimmed because resolvers and zone editors disagree about it, and the comparison is case-sensitive because the token is hex from our own generator rather than something a human chose.
func ClickSource ¶ added in v0.2.0
ClickSource resolves a raw `src` value against the vocabulary. The second return is false for anything unrecognised, which is every value this product did not put in a QR code itself.
func ClickSourceCode ¶ added in v0.3.0
ClickSourceCode is the value stored for a scan of a named code.
**`qr:<slug>`, into the referrer dimension the bare `qr` already lives in.** No new column, no new rollup pass, and no change to RollupDimensionDaily — per-code counts are a filter over values the existing dimension already carries, read by exactly the query every other breakdown is read by. That is what keeps this milestone on the near side of the line campaign analytics was deferred behind: a new pass over `click_events` grouped by a mostly-null column is the cost that deferred it, and this adds none.
The colon is what makes the namespace safe. `referrer_host` otherwise holds hostnames and the `direct` sentinel, and a colon cannot appear in a hostname, so `qr:` prefixes a set of values nothing else can collide with.
The default code — the one whose payload carries no ClickCodeParam at all — is stored as the bare ClickSourceQR, unchanged from M41. Every code this product printed before M50 carries that payload, so every one of them goes on being counted where it has always been counted.
func IsAutomationAction ¶ added in v0.2.0
IsAutomationAction reports whether a name is in the action vocabulary.
func IsAutomationTrigger ¶ added in v0.2.0
IsAutomationTrigger reports whether a name is in the trigger vocabulary.
func IsEmptyRuleConditions ¶ added in v0.2.0
func IsEmptyRuleConditions(c RuleConditions) bool
IsEmptyRuleConditions reports whether nothing at all is set.
func IsVariantKind ¶ added in v0.2.0
IsVariantKind reports whether a kind is one M36 manages.
func IsWebhookEvent ¶ added in v0.2.0
IsWebhookEvent reports whether a name is in the vocabulary.
func Match ¶ added in v0.2.0
func Match(c RuleConditions, s RuleSubject) bool
Match reports whether a request satisfies every condition set on a rule.
Order is by cost, not by correctness: an AND of independent tests gives the same answer whatever order it runs in, so the cheap ones — string comparisons against values the request already carries — run first and the two that can cost a lookup run last. A rule that pairs "mobile" with "city is London" therefore resolves no city at all for the ninety-odd percent of traffic that is not mobile.
func NewQRCodeSlug ¶ added in v0.3.0
func NewQRCodeSlug() string
NewQRCodeSlug returns a slug for a new code.
crypto/rand rather than a counter, because a slug is printed and a predictable one invites somebody to guess a neighbouring code's name and attribute traffic to it. It is not a secret — anybody holding the printed code can read it — so the entropy is sized as a handle, not as a credential.
crypto/rand.Read is documented never to fail since Go 1.24; it panics instead, which is the same contract the rest of this package relies on.
func NewVerificationToken ¶ added in v0.2.0
func NewVerificationToken() string
NewVerificationToken mints a challenge value.
Sixteen bytes of crypto/rand, hex-encoded. It has to be unguessable: a token somebody could predict would let them publish the record for a hostname *before* registering it, and the record is the whole proof. Hex rather than base64 because it goes into a DNS TXT record a person copies by hand, and a character set with no case distinction and no punctuation is the one that survives that.
func QRCodeSlugOf ¶ added in v0.3.0
QRCodeSlugOf returns the slug inside a stored source value, and whether the value named a code at all.
The inverse of ClickSourceCode, and the reader's half of it: the analytics store the slug and the dashboard shows the label, so something has to turn one back into the other. The bare `qr` returns ("", false) — it is a scan of the default code, which is a code, but not one named by a slug.
func SameParent ¶ added in v0.2.0
SameParent reports whether two parent references point at the same place, nil meaning the top level.
func SlugifyCampaign ¶ added in v0.2.0
SlugifyCampaign reduces a string to lowercase letters, digits and single hyphens.
ASCII-only by construction: anything outside `a-z0-9` becomes a separator, so a name written in a non-Latin script slugs to the empty string and the caller is asked for a slug rather than handed a percent-encoded one. A slug is what goes in a URL and in a `?campaign=` filter, and one that survives being copied out of a browser bar is worth more than one that preserves the name.
func SortFoldersByName ¶ added in v0.2.0
func SortFoldersByName(folders []Folder)
SortFoldersByName orders folders for assembly. Exported for the service, which reads them from one query and hands them straight to NewFolderTree.
func ValidQRCodeSlug ¶ added in v0.3.0
ValidQRCodeSlug says whether a string could be one of this product's slugs.
**A shape test and never a membership test.** Whether a slug names a code of *this link* is answered by the link's own slug list, on the redirect path against the snapshot and in the service against the stored rows. This function exists so the redirect can refuse a hostile value by length and alphabet before it scans anything, and so a stored value can be read back with the same rules it was written under.
The empty string is not valid here. It is the default code's slug and it is never carried in a payload, so a request presenting it is a request presenting a parameter with nothing in it.
func ValidateRuleConditions ¶ added in v0.2.0
func ValidateRuleConditions(c RuleConditions) error
ValidateRuleConditions checks a decoded condition set, returning nil or ValidationErrors.
Types ¶
type AutomationRule ¶ added in v0.2.0
type AutomationRule struct {
ID uuid.UUID `json:"id"`
WorkspaceID uuid.UUID `json:"workspace_id"`
Name string `json:"name"`
Trigger string `json:"trigger"`
// TriggerConfig is the threshold, and today it holds exactly one key. It
// stays jsonb — 00600 put it there and M43 does not promote it to a column,
// because the part of a rule most likely to grow is the part that decides
// *which* subjects count, and that is the definition of what belongs in
// jsonb under the rule every Phase 2 milestone inherits.
TriggerConfig AutomationTriggerConfig `json:"trigger_config"`
// Actions is the ordered list, run in order. Order is the caller's: a rule
// that notifies and then archives reads better in an inbox than one that
// archives and then notifies, and nothing here reorders it.
Actions []string `json:"actions"`
Enabled bool `json:"enabled"`
Created time.Time `json:"created_at"`
Updated time.Time `json:"updated_at"`
// LastFiredAt is the **watermark**, and calling it a diagnostic would be a
// misreading with consequences.
//
// A rule sees only subjects strictly after this watermark, and the claim
// that fires a rule advances it in the same statement. That is what stops a
// rule triggering itself: a link that expired at 09:00 is matched once, the
// watermark moves past 09:00, and no later run can see it again. Remove the
// advance and the rule fires on that same link on every tick, forever —
// which is the runaway TestAnAutomationDoesNotFireTwiceForOneSubject exists
// to catch.
//
// "Strictly after" is measured against the pair (this instant, the last
// subject's id) — `last_fired_subject_id`, added by 03600 and, like
// `last_checked_at`, not carried on this struct because nothing outside the
// evaluator reads it. The id half is what lets a run capped mid-way through
// subjects sharing one timestamp resume inside the tie group instead of
// skipping its remainder forever.
//
// It is therefore set when a rule is created and when a disabled rule is
// switched back on, not left NULL until the first firing. A NULL watermark
// on a rule created today would mean "every link that ever expired", and a
// rule re-enabled after a month would otherwise fire for the whole backlog
// the moment somebody flipped the switch.
//
// **It is not the scheduler's cursor**, and conflating the two is F83. Which
// rules a run looks at is ordered by `last_checked_at` — a separate column
// (03100), not carried on this struct because nothing outside the evaluator
// reads it. A run that matched nothing leaves this value exactly where it
// was, on purpose, so that sub-threshold matches accumulate; a column that
// stands still whenever a rule is idle cannot also be the thing that decides
// whose turn it is.
LastFiredAt *time.Time `json:"last_fired_at"`
}
AutomationRule is one standing instruction as the product understands it.
type AutomationSource ¶ added in v0.2.0
type AutomationSource string
AutomationSource names a thing in the database that a trigger reads or an action writes.
The granularity is what makes the disjointness assertion below honest. Both `destination.blocked` and an archive touch `audit_log`, so a source named "audit_log" would either report a false intersection or force the test to be relaxed into meaninglessness. They are named at the granularity the queries actually filter at — one action of the log, not the log — and the queries are written to match.
const ( // SourceLinkExpiry is `links.expires_at`. Read by TriggerLinkExpired and // written by nothing here: an automation may archive a link, and archiving // must never move the expiry, or "link expired -> archive link" would re-arm // itself on every tick. SourceLinkExpiry AutomationSource = "links.expires_at" // SourceClickBudget is `link_click_budget.exhausted_at`, stamped by the gate. SourceClickBudget AutomationSource = "link_click_budget.exhausted_at" // SourceBlockedAudit is `audit_log` rows whose action is destination.blocked. SourceBlockedAudit AutomationSource = "audit_log(destination.blocked)" // SourceNotification is `notifications`. Nothing triggers on an inbox. SourceNotification AutomationSource = "notifications" // SourceWebhookQueue is `webhook_deliveries`. Nothing triggers on the queue, // which is the whole reason EventAutomationFired is not a trigger name. SourceWebhookQueue AutomationSource = "webhook_deliveries" // SourceLinkStatus is `links.status` and `links.archived_at`. Deliberately // *not* SourceLinkExpiry, and the split is the load-bearing part. SourceLinkStatus AutomationSource = "links.status" // SourceAutomationAudit is `audit_log` rows whose action is automation.fired. SourceAutomationAudit AutomationSource = "audit_log(automation.fired)" )
type AutomationTriggerConfig ¶ added in v0.2.0
type AutomationTriggerConfig struct {
// MinCount is how many subjects must have accumulated before the rule fires
// at all. Zero and one both mean "fire on the first one"; the zero value is
// legal so an omitted config is a valid config.
//
// The subjects do not go away while the threshold is unmet: the watermark
// does not advance on a run that did not fire, so they accumulate and the
// rule fires when the count is reached. A threshold that discarded what it
// counted would be a threshold nobody could reason about.
MinCount int `json:"min_count"`
}
AutomationTriggerConfig is the whole of `trigger_config` today.
One key. It is a struct rather than a map so the API shape is discoverable and an unknown key is refused rather than stored and ignored, and it is marshalled into the existing jsonb column rather than given columns of its own.
func (AutomationTriggerConfig) Threshold ¶ added in v0.2.0
func (c AutomationTriggerConfig) Threshold() int
Threshold is MinCount with its floor applied.
type BotPolicy ¶ added in v0.2.0
type BotPolicy string
BotPolicy is a link's own bot-blocking setting.
The zero value is the empty string and means Inherit, which matters because a cached snapshot written by a build that predates this feature decodes with the field absent. Reading that as "inherit" is right in both directions: it is the column default, and on such an instance no domain can be blocking anything yet either.
const ( // BotInherit takes the domain's answer. The default for every link. BotInherit BotPolicy = "inherit" // BotBlock refuses bots on this link whatever the domain says. BotBlock BotPolicy = "on" // BotAllow lets bots through — unless the domain enforces, which is the one // case a link cannot overrule and the reason enforcement exists. BotAllow BotPolicy = "off" )
func ParseBotPolicy ¶ added in v0.2.0
ParseBotPolicy reads a link setting from the wire, reporting whether it was one of the three. An empty string is Inherit rather than invalid, so an API client omitting the field and one sending "" mean the same thing.
type Campaign ¶ added in v0.2.0
type Campaign struct {
ID uuid.UUID `json:"id"`
WorkspaceID uuid.UUID `json:"workspace_id"`
Name string `json:"name"`
Slug string `json:"slug"`
Description string `json:"description"`
// StartsAt and EndsAt describe the schedule and enforce nothing. See the
// package comment above.
StartsAt *time.Time `json:"starts_at,omitempty"`
EndsAt *time.Time `json:"ends_at,omitempty"`
LinkCount int64 `json:"link_count"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
Campaign is one campaign as the product understands it.
LinkCount is computed rather than stored, like Folder.LinkCount, so it cannot drift from the links that actually carry the campaign.
type DomainBotPolicy ¶ added in v0.2.0
type DomainBotPolicy string
DomainBotPolicy is the domain's setting, as one value.
Stored as two booleans (`block_bots`, `block_bots_enforced`) because they answer two questions, and collapsed to three states here because that is what precedence actually branches on. The fourth combination of the two booleans — enforced without blocking — is refused by a CHECK constraint in migration 01800, so it never reaches this type.
const ( // DomainBotsOff blocks nothing. The default, and the zero value for the // same snapshot-compatibility reason as BotPolicy. DomainBotsOff DomainBotPolicy = "off" // DomainBotsOn blocks bots on links that have not said otherwise. DomainBotsOn DomainBotPolicy = "on" // DomainBotsEnforced blocks bots on every link beneath it, including the // ones whose owners set BotAllow. DomainBotsEnforced DomainBotPolicy = "enforced" )
func DomainBots ¶ added in v0.2.0
func DomainBots(blockBots, enforced bool) DomainBotPolicy
DomainBots folds the two stored booleans into the policy.
func (DomainBotPolicy) Booleans ¶ added in v0.2.0
func (p DomainBotPolicy) Booleans() (blockBots, enforced bool)
Booleans is the inverse of DomainBots, for the surfaces that render or store the two switches rather than the folded value.
type FieldError ¶
type FieldError struct {
Field string `json:"field"`
Code string `json:"code"`
Message string `json:"message"`
}
FieldError is a per-field validation failure, so a form can highlight the offending input rather than showing one opaque message.
type Folder ¶ added in v0.2.0
type Folder struct {
ID uuid.UUID `json:"id"`
WorkspaceID uuid.UUID `json:"workspace_id"`
ParentID *uuid.UUID `json:"parent_id,omitempty"`
Name string `json:"name"`
// Depth is 1 for a folder with no parent.
Depth int `json:"depth"`
// LinkCount is the links filed *directly* in this folder, never its
// descendants' — it has to mean the same thing as the number of rows the
// links list shows when this folder is the filter.
LinkCount int64 `json:"link_count"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
Folder is one folder as the product understands it.
Depth and LinkCount are computed rather than stored: Depth by walking the tree this folder is in, LinkCount by counting the links filed directly in it. Neither is a column, so neither can drift.
type FolderTree ¶ added in v0.2.0
type FolderTree struct {
// contains filtered or unexported fields
}
FolderTree is a workspace's folders, assembled.
Built from a flat list, in one pass, and it answers every structural question the service asks. Nothing in it queries anything, which is what makes the cycle rule testable without a database.
func NewFolderTree ¶ added in v0.2.0
func NewFolderTree(folders []Folder) FolderTree
NewFolderTree assembles folders into a tree.
The input needs only ID, ParentID, Name and LinkCount; Depth is filled in here. Siblings keep the caller's order, which ListFolders makes name order.
**Anything unreachable from a root is appended at the end as a root.** A parent that is missing, or a cycle that somehow reached the table, would otherwise make those folders — and every link in them — vanish from a page whose whole job is to show where things are. Losing rows from a view is how a data problem becomes a support ticket about deleted links, so they are shown instead, at the top level, where they can be moved.
func (FolderTree) Depth ¶ added in v0.2.0
func (t FolderTree) Depth(id *uuid.UUID) int
Depth is how deep a folder sits; 1 for a folder with no parent. A folder the tree does not hold — which is how "no parent" is spelled at the call sites — is depth 0, so that `Depth(parent) + 1` is the depth of a new child in both cases without the caller branching.
func (FolderTree) Flat ¶ added in v0.2.0
func (t FolderTree) Flat() []Folder
Flat returns the folders depth-first, parents before their descendants.
func (FolderTree) Get ¶ added in v0.2.0
func (t FolderTree) Get(id uuid.UUID) (Folder, bool)
Get returns one folder.
func (FolderTree) Height ¶ added in v0.2.0
func (t FolderTree) Height(id uuid.UUID) int
Height is how many levels a folder's subtree occupies, counting itself. A leaf is 1.
func (FolderTree) IsAncestor ¶ added in v0.2.0
func (t FolderTree) IsAncestor(ancestor, id uuid.UUID) bool
IsAncestor reports whether `ancestor` is `id` itself or sits above it.
This is the cycle rule, and it is stated as "is the proposed parent inside the subtree being moved" rather than as "does the subtree contain the parent" because that is the direction the walk is cheapest in: the chain upward from any folder is at most MaxFolderDepth long, whatever the tree's shape.
func (FolderTree) Len ¶ added in v0.2.0
func (t FolderTree) Len() int
Len is how many folders the tree holds.
func (FolderTree) MoveRefusal ¶ added in v0.2.0
func (t FolderTree) MoveRefusal(id uuid.UUID, parent *uuid.UUID) *FieldError
MoveRefusal reports why `id` may not become a child of `parent`, or nil when the move is allowed. A nil parent is the top level.
**One definition, two callers, and that is the reason it is here rather than in the service.** internal/link returns whatever this produces as the move's validation error; the dashboard's tree calls it to decide which rows to offer a "Move here" button on. Written twice, the page would eventually offer a destination the service refuses — a button that fails is worse than no button, and the drift would be invisible until somebody clicked it.
Moving a folder to where it already is is not a refusal. It is a no-op the service performs happily; the page declines to offer it separately, because a button that changes nothing is noise rather than an error.
func (FolderTree) SiblingNamed ¶ added in v0.2.0
SiblingNamed returns the folder with this name directly under `parent`, if there is one. Case-insensitive, matching migration 02400's unique index.
type Link ¶
type Link struct {
ID uuid.UUID `json:"id"`
WorkspaceID uuid.UUID `json:"workspace_id"`
Alias string `json:"alias"`
ShortURL string `json:"short_url"`
URL string `json:"url"`
Title string `json:"title,omitempty"`
Description string `json:"description,omitempty"`
Status LinkStatus `json:"status"`
Tags []Tag `json:"tags"`
// FolderID is where the link is filed (M38), or nil for a link in no folder
// — which every link starts as and most stay.
//
// The id and not the name. A name here would be right on the day it was
// written and stale the moment somebody renamed the folder, and the only
// callers that want one — the dashboard's list and detail pages — have
// already loaded the tree to draw their folder controls, so they resolve it
// from that. An API client that wants names asks GET /folders once rather
// than being sent a copy on every row of every page.
FolderID *uuid.UUID `json:"folder_id,omitempty"`
// CampaignID is the campaign this link belongs to (M41), or nil. The id and
// not the name, for exactly the reason FolderID gives above.
//
// A campaign and a folder are different questions and a link answers both:
// a folder is where the link lives and a campaign is what it is for, so a
// launch link filed under Product can still belong to Summer 2026.
CampaignID *uuid.UUID `json:"campaign_id,omitempty"`
// ForwardQuery merges the incoming query string into the destination on
// redirect. Off by default: destinations were configured deliberately, and
// most callers do not expect ?utm_source to reach them.
ForwardQuery bool `json:"forward_query"`
// ForwardPath appends the visitor's extra path segments to the destination,
// so /{alias}/reviews reaches the destination's own /reviews. Off by
// default, and for a sharper reason than ForwardQuery: with it on, one
// alias answers an unbounded set of URLs rather than one.
ForwardPath bool `json:"forward_path"`
// BotBlocking is this link's own setting, not the answer. What is actually
// in effect depends on the domain above it, and only domain.BlocksBots
// decides that — reporting the resolved boolean here instead would be a
// second answer that a reader could compare against the first.
BotBlocking BotPolicy `json:"bot_blocking"`
ExpiresAt *time.Time `json:"expires_at,omitempty"`
// The gates (M35). What a link demands before it will redirect anybody.
//
// **HasPassword, never the password and never its hash.** A management API
// that returned either would make every reader of a link a reader of its
// secret, and the whole point of hashing it is that nothing can hand it back.
// Setting one is a write-only field on the request types.
HasPassword bool `json:"has_password"`
// MaxClicks caps how often the link may be followed; OneTime is the same
// gate fixed at one. Both are the *limit*, never the remaining budget —
// that is a durable counter reported separately, because a number this
// struct carried would be a snapshot of a value that moves on every click.
MaxClicks *int64 `json:"max_clicks,omitempty"`
OneTime bool `json:"one_time"`
// RequireSignature refuses any request that does not carry a valid,
// unexpired HMAC signature for this alias.
RequireSignature bool `json:"require_signature"`
// ClicksConsumed is how much of the budget has been spent, and it is exact —
// unlike ClickCount below. Populated only where the caller asked for one
// link rather than a page of them, because it is a second query per link.
ClicksConsumed *int64 `json:"clicks_consumed,omitempty"`
// Approximate: updated in batches with the click events, so it lags by up
// to one flush interval and can lose a batch on an unclean shutdown.
// Nothing that must be exact may read it. **Deliberately not the counter the
// max-click gate reads** — see internal/gate and migration 02100.
ClickCount int64 `json:"click_count"`
LastClickAt *time.Time `json:"last_click_at,omitempty"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
ArchivedAt *time.Time `json:"archived_at,omitempty"`
}
Link is a short link as the product understands it.
type LinkFilter ¶
type LinkFilter struct {
WorkspaceID uuid.UUID
Search string
TagIDs []uuid.UUID
// FolderID narrows to one folder (M38). Unfiled narrows to the links that
// are in none, which is a different question from "no filter" and cannot be
// asked with a nil id. Setting both is Unfiled winning, because a request
// naming a folder *and* asking for the unfiled ones has contradicted itself
// and the empty answer is the honest one.
FolderID *uuid.UUID
Unfiled bool
// CampaignID narrows to one campaign (M41). Uncampaigned narrows to the
// links carrying none, and the pair works exactly as the folder pair above
// does, including which one wins when a request asks for both.
CampaignID *uuid.UUID
Uncampaigned bool
// DomainID narrows to the links served on one hostname (M40). Nil is no
// filter. There is no `unhosted` counterpart, unlike the folder pair above:
// links.domain_id is NOT NULL, so every link is on exactly one domain and
// there is no third state to ask about.
DomainID *uuid.UUID
Status LinkStatus
Sort LinkSort
Cursor string
Limit int32
IncludeTotal bool
}
LinkFilter describes a link query.
type LinkStatus ¶
type LinkStatus string
LinkStatus is the lifecycle state of a link.
const ( StatusActive LinkStatus = "active" StatusArchived LinkStatus = "archived" StatusExpired LinkStatus = "expired" StatusDisabled LinkStatus = "disabled" )
func EffectiveStatus ¶
func EffectiveStatus(stored LinkStatus, expiresAt *time.Time, now time.Time) LinkStatus
EffectiveStatus is the status a link presents to the outside world.
Expiry is a timestamp, never a stored status. Nothing writes 'expired' to the column, because a written status is stale from the moment the expiry passes until whatever job notices — and that window is exactly when somebody is looking at the link asking why it stopped working.
The redirect path has always derived it this way, which is how an expired link came to answer 410 while every management surface still called it active. The rule matches Snapshot.Decide, including that expiry outranks an archived status: if the two disagreed, this would be the same bug in a smaller form.
type Page ¶
type Page[T any] struct { Items []T `json:"items"` NextCursor string `json:"next_cursor,omitempty"` HasMore bool `json:"has_more"` Total *int64 `json:"total,omitempty"` }
Page is a keyset-paginated result.
Cursor rather than offset: offset pagination re-scans skipped rows and, more importantly, silently duplicates or drops entries when rows are inserted while a user is paging. Total is optional because counting costs a scan the common page load should not pay for.
type RoutingRule ¶ added in v0.2.0
type RoutingRule struct {
ID uuid.UUID `json:"id"`
LinkID uuid.UUID `json:"link_id"`
// Priority orders evaluation: lower wins, and the first rule that matches
// short-circuits. Ties are broken by creation order so that two rules with
// the same priority still evaluate deterministically.
Priority int32 `json:"priority"`
URL string `json:"url"`
Conditions RuleConditions `json:"conditions"`
Enabled bool `json:"enabled"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
RoutingRule is a rule as the API and the dashboard see it.
type RuleConditions ¶ added in v0.2.0
type RuleConditions struct {
// Geographic. Resolved transiently on the redirect path and never stored
// against a click — see internal/geoip and the Analytics scope row.
Country []string `json:"country,omitempty"`
Region []string `json:"region,omitempty"`
City []string `json:"city,omitempty"`
// Derived from the request itself.
Language []string `json:"language,omitempty"`
Browser []string `json:"browser,omitempty"`
OS []string `json:"os,omitempty"`
Device []string `json:"device,omitempty"`
Referrer []string `json:"referrer,omitempty"`
// Query is matched against the visitor's query string: the key is a
// parameter name, the values are what that parameter may be.
Query map[string][]string `json:"query,omitempty"`
// UTM is the same test against `utm_`-prefixed parameters, with the prefix
// left off the key. Separate from Query because it is one of the conditions
// the scope row names, it has its own control in the dashboard, and writing
// `utm_source` into a general query condition puts the campaign vocabulary
// somewhere nothing can find it again.
UTM map[string][]string `json:"utm,omitempty"`
// Time is evaluated against the clock at request time, never at cache time.
Time *RuleTime `json:"time,omitempty"`
// Returning is the within-day returning-visitor test (D2). True requires a
// visitor seen earlier today, false requires one that was not. A pointer
// because "not set" and "must be new" are different conditions.
Returning *bool `json:"returning,omitempty"`
}
RuleConditions is the `conditions` jsonb of a routing_rules row.
**Every present condition must hold, and within one condition any listed value matches.** AND across the keys, OR inside them. That is the reading people expect from a rule builder and it is the only one that composes: a rule that matched on *any* key could never be narrowed, because adding a condition would widen it.
The zero value matches everything, which is why the validator refuses to store it — see ValidateRuleConditions. A rule that matches everything short-circuits every rule below it, and a person who wrote one by accident would see the rules underneath simply stop working.
Every field is omitempty, so a stored row carries only the conditions somebody actually set, and a snapshot carries the same bytes rather than a dozen nulls per rule on the hottest path in the product.
func ParseRuleConditions ¶ added in v0.2.0
func ParseRuleConditions(raw []byte) (RuleConditions, error)
ParseRuleConditions reads conditions from the wire, refusing anything it does not understand.
Strict about unknown keys for the reason decodeJSON is strict about unknown fields: a client that misspells `contry` and gets a 200 believes it has a geographic rule, and what it has is a rule that matches everybody. The one unknown key with a message of its own is `cookies`.
type RuleNeeds ¶ added in v0.2.0
RuleNeeds is which expensive lookups a link's rules can ask for.
Computed once per request from the snapshot's rules — a walk over a handful of structs — so that a link whose rules never mention a city resolves no city, and a link whose rules never mention a returning visitor makes no Redis call. The alternative is asking the subject and letting it be lazy, which works for the redirect path and does not work for the click recorder: whether to *maintain* the returning-visitor set is a decision taken before any condition is evaluated.
func NeedsOf ¶ added in v0.2.0
func NeedsOf(conds []RuleConditions) RuleNeeds
NeedsOf summarizes what a whole rule list can ask for.
type RuleSubject ¶ added in v0.2.0
type RuleSubject interface {
Country() string
Region() string
City() string
Language() string
Browser() string
OS() string
Device() string
ReferrerHost() string
// QueryParam returns every value the visitor sent for a parameter.
QueryParam(name string) []string
// Returning reports whether this visitor was seen earlier today. False when
// there is no Redis to ask — see D2 and the returning-visitor docs.
Returning() bool
// Now is the instant the request is being decided at, which is the request's
// own clock reading and never the one the snapshot was cached at.
Now() time.Time
}
RuleSubject is everything a rule may ask about a request.
An interface rather than a struct of values, and that is the milestone's hot-path budget showing up in a type. A city lookup is an mmap walk and the returning-visitor test is a Redis round trip; neither may happen for a link whose rules do not ask. So the caller supplies something that resolves each answer when it is first wanted and remembers it, and Match asks for nothing it does not need.
Every method returns the zero value when the answer is unknown — no database, an address that resolves to nothing, a header that was not sent. A condition tested against an unknown value does not match, which is the safe direction: an unresolvable request falls through to the link's own destination rather than being routed somewhere on the strength of a blank.
type RuleTime ¶ added in v0.2.0
type RuleTime struct {
// Days are lowercase three-letter weekday names — mon, tue, wed, thu, fri,
// sat, sun. Empty means every day.
Days []string `json:"days,omitempty"`
// From and To are "HH:MM" in TZ. A window whose To is before its From wraps
// past midnight, which is what somebody writing 22:00–06:00 means.
From string `json:"from,omitempty"`
To string `json:"to,omitempty"`
// TZ is an IANA name. Empty is UTC.
//
// Stored as the name rather than as an offset, because an offset is wrong
// twice a year: a rule written as +01:00 in July starts firing an hour late
// in November, silently, on exactly the campaign somebody set up in summer.
TZ string `json:"tz,omitempty"`
}
RuleTime is a date/time condition.
Deliberately a weekday set and a time window rather than a calendar range. A campaign that runs "weekday office hours in London" is the thing people actually ask for; a fixed date range is expressed by the link's own expiry, which already exists and which the redirect path already honours.
type Split ¶ added in v0.2.0
type Split struct {
// Kind is the kind of the link's arms — weighted or sequential — or "" when
// the link has none. A link's arms are all one kind; see ValidateSplitKind.
Kind string `json:"kind"`
Variants []Variant `json:"variants"`
// Fallback is the link's fallback rule, if it has one.
Fallback *Variant `json:"fallback,omitempty"`
}
Split is a link's whole split test.
type Tag ¶
type Tag struct {
ID uuid.UUID `json:"id"`
Name string `json:"name"`
Color string `json:"color,omitempty"`
LinkCount int64 `json:"link_count,omitempty"`
}
Tag groups links within a workspace.
type ValidationErrors ¶
type ValidationErrors []FieldError
ValidationErrors is a collection of field errors. Like config validation, every problem is reported at once rather than one per round trip.
func QRCodeLabelErrors ¶ added in v0.3.0
func QRCodeLabelErrors(label string) ValidationErrors
QRCodeLabelErrors validates a label, returning the field errors it earns.
Empty is allowed and means "unnamed": the default code starts that way, and a surface that insisted on a name before a workspace had two codes would be asking a question with no answer yet. What is refused is a label that is only whitespace, which reads as a name in the database and as nothing on the page.
func ValidateAutomationActions ¶ added in v0.2.0
func ValidateAutomationActions(trigger string, actions []string) ([]string, ValidationErrors)
ValidateAutomationActions checks an action list against the vocabulary and against the trigger it is attached to.
Deduplicated and sorted into the canonical vocabulary order, so two rules that mean the same thing compare equal and the stored array is stable. An unknown name is refused rather than dropped, for the reason ValidateWebhookEvents gives: silently ignoring one leaves somebody with a rule they believe does something and a scheduler that does nothing.
func ValidateAutomationRuleName ¶ added in v0.2.0
func ValidateAutomationRuleName(s string) ValidationErrors
ValidateAutomationRuleName bounds the label.
func ValidateAutomationTrigger ¶ added in v0.2.0
func ValidateAutomationTrigger(name string) ValidationErrors
ValidateAutomationTrigger checks a trigger name against the vocabulary.
func ValidateAutomationTriggerConfig ¶ added in v0.2.0
func ValidateAutomationTriggerConfig(c AutomationTriggerConfig) ValidationErrors
ValidateAutomationTriggerConfig bounds the one key.
func ValidateCampaignDescription ¶ added in v0.2.0
func ValidateCampaignDescription(desc string) (string, ValidationErrors)
ValidateCampaignDescription trims and bounds the description.
func ValidateCampaignName ¶ added in v0.2.0
func ValidateCampaignName(name string) (string, ValidationErrors)
ValidateCampaignName trims and checks a name.
func ValidateCampaignSchedule ¶ added in v0.2.0
func ValidateCampaignSchedule(starts, ends *time.Time) ValidationErrors
ValidateCampaignSchedule checks the two bounds against each other.
func ValidateCampaignSlug ¶ added in v0.2.0
func ValidateCampaignSlug(slug, name string) (string, ValidationErrors)
ValidateCampaignSlug normalizes and checks a slug, deriving one from the name when none is given.
**Lowercased here rather than only in the index.** `campaigns_workspace_slug_key` is on `lower(slug)`, so "Summer" and "summer" already collide; storing the case somebody typed would mean two campaigns whose slugs look different, one of which cannot be created. Folding on the way in makes the stored value and the constraint agree.
func ValidateFolderName ¶ added in v0.2.0
func ValidateFolderName(name string) (string, ValidationErrors)
ValidateFolderName checks a name on its own, before anything is looked up.
func ValidateHostname ¶ added in v0.2.0
func ValidateHostname(raw string) (string, ValidationErrors)
ValidateHostname normalizes and checks a hostname, returning the form to store and every reason it cannot be stored.
Normalization is lowercasing, trimming surrounding space, and dropping a single trailing dot. The trailing dot is the fully-qualified form and is legitimate to type; storing it would make `example.com.` and `example.com` two rows the unique index treats as different names for one host. Everything else that differs is a different name.
func ValidateSplitKind ¶ added in v0.2.0
func ValidateSplitKind(kind, existing string) ValidationErrors
ValidateSplitKind refuses a kind, and refuses mixing two.
A link's arms are all weighted or all sequential, and that is a product decision rather than a limitation: "40% of visitors, in rotation" has no meaning, and letting the two kinds coexist would mean the redirect path deciding which one wins — a precedence rule nobody asked for, applied to a state nobody meant to create.
func ValidateWebhookDescription ¶ added in v0.2.0
func ValidateWebhookDescription(s string) ValidationErrors
ValidateWebhookDescription bounds the label.
func ValidateWebhookEvents ¶ added in v0.2.0
func ValidateWebhookEvents(events []string) ([]string, ValidationErrors)
ValidateWebhookEvents checks a subscription against the vocabulary.
An unknown name is refused rather than dropped. Silently ignoring one would leave somebody with a webhook they believe is subscribed to something and a receiver that never fires, which is the failure mode a closed vocabulary exists to prevent rather than to cause.
The result is deduplicated and sorted, so the stored array is canonical and two subscriptions that mean the same thing compare equal.
func ValidateWeight ¶ added in v0.2.0
func ValidateWeight(weight int32) ValidationErrors
ValidateWeight refuses a weight the redirect path could not honour.
Zero is permitted and means "this arm receives nothing" — a way to park an arm of a running test without deleting it and losing the clicks already attributed to its destination. Every arm at zero is refused by ValidateSplit, because a split that can choose nothing is a link whose destination silently reverts.
func (ValidationErrors) Error ¶
func (v ValidationErrors) Error() string
func (ValidationErrors) Is ¶
func (v ValidationErrors) Is(target error) bool
func (ValidationErrors) Or ¶
func (v ValidationErrors) Or(err error) error
type Variant ¶ added in v0.2.0
type Variant struct {
ID uuid.UUID `json:"id"`
LinkID uuid.UUID `json:"link_id"`
Kind string `json:"kind"`
URL string `json:"url"`
// Weight is the arm's share, and it is meaningful only for `weighted`. A
// sequential arm carries whatever weight its destination row has and nothing
// reads it, which is the honest encoding of "this kind does not use weights"
// — the alternative is a column that is NULL for half the rows in a table
// whose CHECK says it cannot be.
Weight int32 `json:"weight"`
// Share is Weight over the sum of the link's enabled weighted arms, as a
// percentage, or zero when the link's split is not weighted. Computed for
// the reader rather than stored, because it changes whenever any *other* arm
// changes and a stored copy would be wrong the moment one did.
Enabled bool `json:"enabled"`
Position int32 `json:"position"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
Variant is one arm of a split test, as the API and the dashboard see it.
The same shape as RoutingRule with the condition set replaced by a weight, because it is the same row: a rule, a destination, an enabled flag. Kept as its own type rather than adding two nullable fields to RoutingRule, so that a client reading a rule list cannot be handed a weight that means nothing and a client reading a split cannot be handed conditions that are never evaluated.
type Webhook ¶ added in v0.2.0
type Webhook struct {
ID uuid.UUID `json:"id"`
WorkspaceID uuid.UUID `json:"workspace_id"`
URL string `json:"url"`
Events []string `json:"events"`
Description string `json:"description"`
Enabled bool `json:"enabled"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
// Secret is the signing key, hex-encoded, and it is set on exactly two
// responses: the one that created the webhook and the one that rotated its
// secret. Empty everywhere else, and `omitempty` so a client cannot tell a
// listing apart from a creation by the field being present and blank.
Secret string `json:"secret,omitempty"`
}
Webhook is one registration as the product understands it.
The secret is absent by construction. It is returned exactly once, from the call that generated it, and after that nothing can read it back out of this type — so no handler, template or log line can leak it by accident.
type WebhookDelivery ¶ added in v0.2.0
type WebhookDelivery struct {
ID uuid.UUID `json:"id"`
Event string `json:"event"`
// Status is pending, delivered, failed or abandoned, as 00600's CHECK
// spells them.
Status string `json:"status"`
Attempts int32 `json:"attempts"`
// ResponseCode is what the receiver answered, or null when there was no
// response at all — a refused connection, a timeout, or this instance
// declining to connect because the name resolved somewhere private.
ResponseCode *int32 `json:"response_code"`
// LastError is the failure in words. Empty on a delivery that succeeded
// first time.
LastError string `json:"last_error,omitempty"`
NextAttemptAt *time.Time `json:"next_attempt_at"`
CreatedAt time.Time `json:"created_at"`
CompletedAt *time.Time `json:"completed_at"`
}
WebhookDelivery is one attempt to hand one event to one receiver.
It is a log entry rather than a queue item as far as any reader is concerned: the fields that make it a queue — the payload, the lease — are not here, because the only questions a person asks of this row are "did it arrive", "what did the receiver say", and "when will it be tried again".