Skip to main content

Onboarding generation

One typed input — a domain — becomes a brand profile, a set of topics, and a set of monitored prompts the runner can execute. Nothing else in the product can produce data until this has run: the schema, the meter and the runner all exist, but without brands and prompts there is nothing to run. Everything lives under lib/onboarding/, reachable at /onboarding, and writes through app/actions/onboarding.ts.

The one input

The domain is the only thing the user types. Brand name, description, industry, positioning, products, market, language and timezone are all derived, and so are the topics and every prompt under them. This is not a UX preference — it is the product’s reason to exist. The buyer is a marketing team, not an engineer (see measurement.md), and every additional required field is a place where a person who does not yet trust the product decides to come back later. Adding a second required input is the change to argue hardest against. Derivation happens in two layers: A country-code TLD wins outright over the model: .fr is a fact about the registration, while “this site reads German” is an inference from prose that one translated page can flip. Only a generic-TLD default (.comUS/en) is open to revision, and parseGenerationResponse discards a model’s market opinion entirely when the TLD already settled it. derive.ts never refuses to guess. An unresolvable TLD still yields a market, because a wrong default one click from right beats an empty select the user has to reason about. An internationalised domain (café.fr) is accepted and stored in its punycode form — new URL applies IDNA, so the accented registrations that exist across the markets the ccTLD table targets do not need a punycode dependency to onboard. What stays refused is anything that is not a domain: an IP literal, a bare hostname, an email address.

The homepage fetch is a server-side request to a host the user typed

site.ts follows redirects itself rather than handing them to the transport, and revalidates every hop rather than only the initial request, up to MAX_REDIRECTS. The body is read from the stream and stopped at MAX_HTML_BYTES rather than buffered and then truncated. Every refusal on that path — a refused hop, a non-ok status, a body that fails mid-read, a page with nothing readable on it — is a GenerationError with code unreachable_site, the only code app/actions/onboarding.ts turns into copy, so anything else would surface as a 500. What each hop is checked for, in shape: the scheme must be https:, so a downgrade to a page an attacker on the path can rewrite is refused; and the hostname must be one normalizeDomainInput accepts, which rejects every literal address — IPv4, IPv6, and the obfuscated forms new URL normalises (0x7f.1 becomes 127.0.0.1) — along with bare hostnames that are not domains. What each hop is checked for, in fact: where the hostname actually resolves. Every request — the first and each hop — goes through fetchPublicUrl, which resolves the name, judges every address it gets back, and refuses the whole request if any one of them is not public unicast. https://metadata.google.internal/ and any name whose record points at loopback, RFC 1918 space, link-local (169.254.169.254), CGNAT, unique-local, multicast, reserved or documentation space is refused with the address named in the copy the user sees. IPv4-mapped IPv6 spellings (::ffff:7f00:1) are the same bytes as their IPv4 originals and are refused as such; for IPv6 the policy is an allowlist of global unicast, so unassigned space is refused by default. The address table lives in address.ts and is pure. One good record and one bad one refuses the name outright, because which one the OS would have picked is not knowable. Nor can the answer change between the check and the connection. A validated address is handed to the transport, which dials that address with a lookup hook that ignores the hostname — so a rebinding resolver has no second answer to give. The hostname is still what SNI, certificate validation and Host use: pinning changes where the packets go, not who the certificate has to be for (a literal address, bracketed IPv6 included, has no name to send, so none is set). See node-transport.ts for why this is node:https rather than fetch (Vercel runs these routes on the Node.js runtime; undici’s equivalent hook is only reachable through a Dispatcher, which no Node built-in exports). An attempt that fails falls back to the next validated address, never to a fresh lookup. fetch walked the whole DNS answer (Happy Eyeballs), and a dual-stack site whose first record is unroutable from this runtime — or serves the wrong certificate — should not become an onboarding that cannot complete; a security fix that quietly breaks a legitimate read gets blamed on the product rather than on the fix. Any rejection moves on, not only a refused connection: a certificate mismatch and an unreadable status do too. That widens nothing, because every candidate passed the same address policy before a socket was opened, each attempt validates the certificate on its own and still fails on its own, a policy refusal throws before the loop and is therefore unreachable from it, and the shared abort signal caps total time across attempts. unreachable_site is reported once every validated address has been tried. Uncompressed content only, deliberately. node:https neither negotiates a content-coding nor decodes one, and no accept-encoding header means “any coding is acceptable” (RFC 7231 §5.3.4) — so site.ts asks for accept-encoding: identity, and a response that carries a content-encoding anyway is refused as unreachable_site rather than TextDecoder-ed into mojibake and summarised back to the customer as their brand profile. The alternative was decompression inside the pinned transport: a compression-bomb surface, plus the size cap that would exist only to defend against it. One request header instead of code that can be attacked, on a path that reads one small document where transfer size buys nothing. Someone will want to “restore” compression support; this is why not. fetchPublicUrl is the reusable guard, not onboarding-specific code: the free audit will aim the same code path with no sign-in in front of it. The flow being reachable only by an authenticated user today is a mitigation on exposure, not the reason the request is safe, and it should not be cited as one. Generation itself is guarded by the same “already onboarded” check the page redirect uses, applied inside the Server Action, and by a rate limit — below.

The rate limit, and why it is in Postgres

Generation costs a homepage fetch and a model call, and it happens before a workspace exists, so there is no usage_periods row to charge it against: that meter counts engine runs, which is what the customer bought. Nothing else stood between a signed-in account and an unbounded loop of paid calls, so generate.ts charges an allowance first. The counter is a database row, not a map in the process. Vercel runs many instances of a Server Action, each with its own memory and each replaced freely, so a process-local counter grants a fresh allowance per instance and the effective limit is limit x instances — unknowable, and largest exactly when traffic is highest. lib/rate-limit/ puts it in the database this project already has. The check and the record are one statement. public.consume_rate_limit is a single insert ... on conflict do update ... returning: concurrent callers contend on one row keyed by (action, subject, window length, window start), so the loser blocks and re-reads the version the winner committed, and each caller’s returned count is distinct. At most one caller can be handed the last slot. A read-then-decide implementation — in SQL or in TypeScript — is the race this shape removes, which is why supabase/migrations/rate-limit.test.ts asserts the statement’s shape rather than its behaviour. The attempt is counted, not the success. A generation that throws still read the homepage and may still have reached the model, so it consumed exactly what the limit bounds; refunding on failure would make a reliably-failing domain a free loop. This is deliberately the opposite of release_run_slot, which refunds a run whose engine call never reached the provider — there the meter is a promise to the customer about what they bought, here the counter is a guard on rekkal’s own bill. The rule stated precisely is an attempt that could have spent money, and the one thing that could not is a generator with no provider key. generateWithAllowance checks isConfigured() — a pure environment read, no network — before it charges anything, and raises not_configured untouched by the counter. So a key-less deploy keeps answering “Generation is not configured on this deployment yet.” however many times it is tried, instead of telling the sixth caller they have used up their brand setups: a limit that punishes the customer for our misconfiguration is both false and unfixable from their end. Every path that reaches the provider still records the attempt first. Three subjects, two windows each, from config.limits.rateLimits.onboardingGeneration. The account is what a scripted loop holds; the workspace is what colleagues share once seats exist, without which three members would simply spend three account allowances against one workspace’s bill; the client address is what survives someone signing up again. Per subject, the hour bounds the burst and the day bounds the bill — 5/hour and 20/day per account, 15/hour and 40/day per workspace, 40/hour and 200/day per address. All three are charged in one consumeAll, so the record-before-spend ordering is one decision rather than three. The address rule is what bounds rekkal’s total spend, and nothing else does. Sign-up is open, so a per-account allowance bounds one account and says nothing about the bill: a fresh account is a fresh allowance, and account creation is free. It is also the only rule that can refuse someone who did nothing wrong — an office, a university and a mobile carrier NAT are one address to us — so it is sized as a backstop, eight full account allowances an hour, and the refusal a genuine user meets is still their own account rule five generations earlier. generate.test.ts pins that ordering rather than trusting the numbers to stay sorted. One header decides who is calling, and it is the one the platform sets. lib/net/client-address.ts reads x-vercel-forwarded-for and nothing else. x-forwarded-for, x-real-ip and forwarded are ordinary hop headers that arrive exactly as the client wrote them, so trusting a chain end-to-end hands an attacker one allowance per invented hop — the precise bound the rule exists to establish. The x-vercel-* prefix is reserved by Vercel’s edge for headers it sets itself, which is what makes it the only value a caller cannot dictate; the last entry is taken when the value is a list, because that is the entry the edge contributed under either overwrite-or-append behaviour, and taking the first would be taking the only one an attacker chooses. IPv6 buckets per /64: a single subscriber routinely holds the whole host half and changes it for free. An address that cannot be established collapses into one shared unknown bucket rather than skipping the rule — a missing header must not mean unbounded spend, which is the same fail-closed posture the limiter takes when it cannot reach its store. The Server Action console.warns when a request lands in that bucket, because otherwise the first symptom of a vanished platform header is strangers refusing each other with copy that reads perfectly plausible. The address is stored keyed-hashed, never as itself. rate_limit_counters is durable, so writing the address would leave a standing record of who visited from where in exchange for nothing the limit needs: a counter needs a key that is stable for the same caller, and that is all. addressSubject HMAC-SHA-256s the already-normalised bucket key under RATE_LIMIT_ADDRESS_SECRET, so granularity is untouched — full IPv4, IPv6 /64, one shared unknown — and the row holds a pseudonym. Keyed rather than a bare digest, because a plain SHA-256 of every IPv4 address is minutes of work and would pseudonymise nothing; and it is the only way to build an ip subject — the id type is branded, so a hand-built one does not compile, and address-subject.test.ts scans every shipped module for the shortcut rather than trusting a reader to remember. A missing secret refuses the generation — the alternatives are storing the plaintext or limiting every visitor as one subject — with the same “try again in a moment” copy an unreachable limiter gets, and a loud log beside it. Two costs, both accepted: nobody can read the table to find which address is abusing the endpoint (and the plaintext is deliberately not logged to compensate, since logs are read more widely and kept longer), and rotating the secret resets every in-flight address window. A counter is kept for seven days after its window closes, then deleted. The pseudonym above is what makes the row safe to hold at all; the retention period is what stops it being held forever. A closed window is inert — consume_rate_limit only ever touches the current window’s row — so the week buys one thing: an abuse report or a bill spike investigated after the weekend it happened on. The number is config.limits.rateLimits.retentionSeconds, deliberately beside the windows it must outlive (7× the longest, so no prune can delete a window that is still open), and /api/cron/prune-rate-limits applies it daily in bounded batches. The full policy, including why a prune cannot block a consuming caller, is in supabase.md. The rules themselves are built by windowRules in lib/rate-limit/, which knows only about numbers, a subject and a label. That is deliberate: the unauthenticated audit later limits by client address against its own action key with the same function and the same derivation, and needs nothing from the onboarding path — passing the subject addressSubject mints, never one built by hand, which the branded AddressSubjectId turns from a convention into a compile error. The subject kind ip was already in the schema, so this rule was a caller change with no migration. Refusal is copy, never a stack trace: “You have reached the limit of 5 brand setups per hour. Try again in about 12 minutes.” When two windows refuse at once, the one that lifts last is reported — telling someone to come back sooner than they can is a promise the next attempt breaks. A refusal states its actual cause: the address rule’s label names the shared network (“…40 brand setups from your network per hour”), because telling someone they exhausted a personal allowance when their office did is a false statement about their own account and one they cannot act on. An allowance that cannot be charged at all fails closed, like the plan resolution beside it.

Corrections are one click

Every derived field lands in the UI as an editable chip or a pre-filled input — never as prose the user would have to rewrite. Positioning attributes and products are text[] columns on brand_profiles for exactly this reason: removing a wrong one is an array update, not a paragraph edit. The cost of a wrong guess is decided entirely by how the correction is presented. A user one click from correct does not abandon the screen, and cannot end up saving a profile they know is wrong because fixing it was tedious.

Topics are the compression layer

Generation produces config.onboarding.topics × config.onboarding.promptsPerTopic prompts (5 × 8 today). The user reviews five topics, then skims what sits under them — an 8× reduction in what has to be read before approving. Approving forty prompts one at a time is not approval, it is authoring. The topic layer is not a wizard device: prompts.topic_id is single-valued, so a topic stays afterwards as the dimension every aggregate can be cut by, with no way for a rollup to double-count by summing across it.

Defaults are maximal, the CTA is an approval

Everything sensible arrives pre-selected, up to the plan’s allowance, spread round-robin across topics so no approved topic ends up measuring nothing. On Free that is 10 of 40 selected; on Pro, all of them. The surplus is deliberate — generating exactly the allowance would make the review screen a formality with nothing to choose between, and unselected prompts are never written, so they cost nothing. The final button says “Looks good”. The default path through the whole flow is consent.

The preview is the app, de-populated

preview-pane.tsx renders the same navigation and the same three screens the user will live in afterwards, starting empty and resolving as each step is approved. Onboarding doubles as the introduction to the app’s structure rather than being a separate wizard aesthetic.

Generation runs without a provider key

Same contract as the engine adapters: one OnboardingGenerator interface, a real OpenAI-backed implementation, and a fixture that replays payloads through parseGenerationResponse — the exact function production uses. Exercising the fixture exercises the validation, capping, deduplication and branding reclassification that run for a real customer, so pnpm test is green with every provider key unset and costs nothing. The fixture has two modes: a hand-authored recording for one domain (a realistic, accented, multi-topic French answer that pins the parser), and a deterministic synthesiser that builds a payload of any requested shape so tests can drive the plan dimension without a recording per case. To click through the flow locally with no key, set REKKAL_ONBOARDING_FIXTURE=1. It is ignored when NODE_ENV=production — an env var alone is one dashboard mistake away from a deployment quietly writing invented profiles under real customers’ names, and looking exactly like a working one.

What the write path guarantees

save.ts is the boundary. The draft travelled through a browser and is user input by the time it comes back, so three things are decided there and nowhere else. Entitlement comes from billing. planForOwner reads the owner’s Stripe subscription, the same function loadWorkspace and createBrand use. It is never a parameter, and it fails closed: a plan that cannot be resolved is a refusal, never a silent free. The plan’s allowance is applied to what is written. sanitizeDraft re-caps the submitted selection against config.limits.<plan>.prompts regardless of what the draft claimed, spreading the truncation across topics the same way the original pre-selection was built. The surviving selection is keyed on position, never on prompt.id: the ids came back from the browser like everything else, and a draft repeating one id would otherwise re-select every prompt sharing it. An edited profile is stored as edited. The draft carries the profile as it was proposed alongside the editable copy, and profileSource compares them, so brand_profiles.source says user when a human corrected a field and generated only when the review screen came back untouched. That is the column a later regeneration reads before deciding it may overwrite the row; a missing snapshot reads as user, because a skipped regeneration is cheaper than a discarded correction. A half-finished onboarding resumes. There is no transaction across PostgREST calls, so a failure between the workspace insert and the prompt insert would otherwise leave a user with a workspace they cannot onboard into. Every step is re-runnable, and “already onboarded” means the workspace has active prompts, not merely that it exists. Two ceilings sit underneath, and they are not pricing:
  • brands_enforce_ceiling (SQLSTATE RK001) — total brand rows per workspace.
  • prompts_enforce_ceiling (SQLSTATE RK002, 20260726120000_onboarding.sql) — total prompt rows per workspace, config.limits.maxPromptsPerWorkspace.
Both exist because brands and prompts carry unrestricted member INSERT policies, so a guard living only in lib/ is not a guard. Onboarding writes prompt rows from model output, which is the write shape that made the brand ceiling necessary in the first place: one malformed generation returning hundreds of topics turns straight into rows, and at the next weekly slot into engine spend.

What onboarding deliberately does not do

  • It does not ask who your competitors are. Competitor discovery reads who else appears in the answers, which produces a truer set than the user would type. That is its own piece of work.
  • It does not run anything. Prompts land is_active = true; the scheduled runner picks them up at the next weekly slot, metered against usage_periods like everything else. See analytics-pipeline.md.
  • It does not touch billing. A free account onboards, gets ten prompts on one engine, and sees the same flow a Pro account sees.