Skip to content

fix(auth): scope session cookies to the API host - #470

Open
vladbisceanu wants to merge 716 commits into
useplunk:mainfrom
vladbisceanu:upstream/13-auth-cookie-domain
Open

fix(auth): scope session cookies to the API host#470
vladbisceanu wants to merge 716 commits into
useplunk:mainfrom
vladbisceanu:upstream/13-auth-cookie-domain

Conversation

@vladbisceanu

Copy link
Copy Markdown

Problem

When the dashboard and API use different deployment hosts, deriving the cookie Domain from the API hostname can produce an invalid or cross-site session cookie. A concrete example is Railway's generated *.up.railway.app hosts: up.railway.app is a private public suffix, so browsers reject Domain=.railway.app. Login returns success, then authenticated requests return 401.

Fix

  • issue a host-only session cookie from the API
  • expire the legacy domain cookie before setting the replacement, and clear both forms on logout
  • use schemeful-site/public-suffix semantics to select SameSite=Lax or None
  • cover the actual serialized login, signup, and logout Set-Cookie headers
  • update privacy copy to match the resulting scope

This keeps same-site custom-domain deployments on Lax, while allowing intentional cross-site HTTPS deployments to use None; Secure without relying on an invalid shared domain.

Verification

  • focused auth-cookie tests: 2 files, 6 tests passed
  • API build passed
  • lint passed with existing warnings only
  • Codex diff review found no discrete correctness issue

Related fork issue: vladbisceanu#13

driaugand others added 30 commits April 24, 2026 11:56
The raw MIME template in sendRawEmail produced a blank line between Content-Type and List-Unsubscribe whenever no custom headers were passed, because the ternary for custom headers expanded to an empty string surrounded by newlines.
Per RFC 5322 §2.1 a blank line terminates the header section, so List-Unsubscribe ended up as the first line of the body. Lenient clients (Gmail, Outlook) recover; strict clients (Thunderbird) do not, breaking one-click unsubscribe and degrading Gmail/Yahoo bulk-sender deliverability signals.
Collect optional headers (custom + List-Unsubscribe) into an array, filter empties, and append them to the Content-Type line with a single newline separator. No codepath can now produce a blank line inside the header block.
driaugand others added 28 commits August 10, 2026 08:54
`{{ }}` already rendered as a chip, but `{% %}` rendered as prose — same font,
weight and colour as the sentence around it. The data layer was visible and the
control flow was invisible, so a template with a few conditionals read as one
undifferentiated block of text.
Decorate logic tags too, deliberately styled apart from the blue value chips
rather than in a second accent colour: outputting a value and branching are
different kinds of thing and should not look like variations of one. Matching is
on the delimiters rather than a list of tag names, so if/else/for/case/assign
and anything Liquid gains later are all covered.
Also resets lastIndex per text node. The shared /g regex kept its offset between
nodes, so a document with several text nodes silently skipped matches.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…n menus
The {{ menu works because it is populated from the project's real contact
fields: nothing has to be remembered. Control flow had no equivalent, so typing
{% now offers the shapes people actually reach for, labelled in plain language
with the Liquid shown underneath. Both tags are inserted together with the caret
between them, so an unbalanced block, the most common reason a template fails to
save, is not reachable through the menu.
Building it surfaced problems in the existing {{ menu, so both now share one
list component and one row grammar: what you are choosing, what it writes, and
what we know about it. Specifically:
- Rows lead with the field name rather than raw {{syntax}}, and carry the type
and coverage the fields endpoint already returned and the UI discarded. A
{{plan}} that only 4% of contacts have renders blank for the rest, and the
only way to discover that was to send the campaign.
- Hover no longer paints a second highlight competing with the keyboard
selection. Pointer movement moves the selection, so exactly one row is ever
marked and it is always the one Enter takes.
- The selected row uses the same neutral-100 highlight as Select and
DropdownMenu instead of inventing a second vocabulary for the same gesture.
- Arrowing past the fold scrolls the selection into view. It previously moved
invisibly.
- Field names are escaped before being interpolated into the list markup. They
come from contact data keys, which are supplied through the API and CSV
import, so a crafted key could inject markup into the dashboard.
- The list is a listbox with options and aria-selected rather than anonymous
divs, and Tab accepts alongside Enter.
- Logic blocks are type-aware: no == "value" on a boolean, no numeric
comparison on a name. Results are capped per field so a query matching
several fields shows several fields rather than one field's variants.
Two supporting fixes:
- Suggestion lists rebind command on update. It closes over the match range,
which grows with every keystroke, so the one captured at onStart deleted the
trigger and left the query behind as literal text. This affected {{ too.
- The syntax strip stays quiet while a menu is open. A half-typed {%ema is not
valid Liquid, and flagging it contradicts the menu offering to finish it.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Picking a block put both tags on one line with the caret between them, so
writing anything multi-line meant pressing Enter from a spot that reads like the
inside of a chip. The block arrived complete but with no room in it.
Blocks are now described as lines, where an empty string is an editable gap, and
the insertion picks its shape from context: on an otherwise empty line the block
is written across paragraphs with the gap already open and the caret in it, and
mid-sentence the same lines collapse to the inline pair as before. An if/else
opens both of its gaps.
Inserting a multi-line block mid-sentence would break the sentence, and an
inline pair on an empty line is the awkward case above, so neither shape works
everywhere — which is why this reads the caret's surroundings rather than
picking one.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Syntax validation is structurally blind to the most common template mistake.
`{% if emai %}` parses, and is then silently false for every contact forever:
the campaign sends, that block renders nothing, and nobody finds out.
The fields endpoint already reports every field a project's contacts carry,
along with what share of them carry it, so the editor can check references
against reality and say two useful things:
- emai matches no field on any contact, and email is one character away.
- trialEndsAt exists, but only 12% of contacts have it, so the other 88% see
this block as empty. That number was previously undiscoverable without
sending the campaign.
Warnings only, shown below the syntax strip and never blocking a save. A field
that legitimately has no data yet is a real scenario: you write the template
before the data starts flowing.
The risk in a lint like this is crying wolf, since a warning that fires on
correct templates teaches people to ignore the strip. So it stays quiet for
loop variables, assigned and captured names, filters and their arguments,
string literals, the legacy ?? fallback, raw blocks, and the HTML entities the
rich-text editor escapes markup into — &gt; would otherwise report a missing
field called gt. It also says nothing at all until the field list has loaded,
when every reference would look like a mistake.
Names bound anywhere silence that name everywhere rather than tracking scope
per block. That can miss a genuine mistake, which is the better failure.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The menu covered if/unless/for and stopped there, so the multi-way branch
people reach for most after a plain conditional — case/when — could only be
written from memory, which is the thing this menu exists to avoid.
Added, each still inserting complete and balanced:
- case/when/else, for picking a version per value. Offered for text, number and
date fields but not booleans, where a multi-way branch on two states is noise.
- contains, for matching within a value.
- for/else, which renders the fallback when the list is empty.
- comment, for notes that never send.
- raw, for showing template markup as literal text. The docs already tell
authors to reach for it and the menu did not offer it.
Also a below-a-value comparison for numbers and dates, which only had above.
The block definitions are now checked against the same parser the API validates
with, both filled and left empty. A typo in a closing tag would otherwise ship
as a menu entry that produces an unsaveable template, and nothing in the type
system would catch it.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Templates used as workflow steps are edited on the same screen as campaign
templates, and are rendered with the trigger's event alongside contact data
(WorkflowExecutionService). Reading `event.plan` is correct there, but the field
lint only knew about contact fields and reported it as a missing one — a warning
firing on a working template, which is the failure mode this lint most needs to
avoid.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
feat!: implement more expressive templates via liquidjs
…erve-reply-to
fix(smtp): preserve Reply-To when relaying to /v1/send
…/js-yaml-4.3.1
build(deps): bump js-yaml from 4.3.0 to 4.3.1
…/nanoid-3.3.18
build(deps): bump nanoid from 3.3.11 to 3.3.18
…/dompurify-3.4.13
build(deps): bump dompurify from 3.4.12 to 3.4.13
The copy across the dashboard was written screen by screen, so the same
concept was phrased several different ways and a lot of text explained
things the UI already said. This is a pass over every user-facing string
in apps/web against one standard, applied consistently.
Systemic patterns removed:
- 25 confirmation dialogs opened with "Are you sure you want to...", then
repeated the question the title already asked. Titles now name the
object ("Delete john@acme.com?"), bodies state only the consequence,
and buttons are verb + object. Cancel dialogs previously rendered
[Cancel] [Cancel Campaign]; the dismiss button now reads "Keep sending".
- 42 success toasts ended in "successfully". The toast is already green.
- 87 "Failed to X" strings gave no cause and no next step. Most sites
already surfaced error.message and only used these as a fallback, so
the fallbacks now name what did not happen and what to do.
- ~20 CardDescriptions restated their own CardTitle. Deleted. The ones
carrying an actual rule were kept and sharpened instead.
- BillingConsumption, BillingInvoices and QuickStart each wrote their
header 3-4 times across loading/error/empty branches. Hoisted to one
header per component.
- 243 Title Case UI strings across 190 distinct labels moved to sentence
case. The app previously shipped both "Send To" and "Send to".
- Required-field markers used three different mechanisms. Now one.
- Terminology: "API Credentials" and "API Keys" were the same card under
two names; cancelled/canceled and .../… were both in use; the templates
page title disagreed with its nav item.
Structural changes:
- Removed the analytics "Performance Insights" card. It dispensed advice
the product cannot substantiate ("above industry average" against no
cited benchmark, "add more compelling calls-to-action") in a product
positioned against exactly that tone. Replaced with two counters drawn
from real data: events triggered and workflow runs started.
- Subscription activation copy was ~75 words over two paragraphs. It is
load-bearing (it prevents billing surprises), so it was restructured
rather than cut: one summary line plus a "Why two charges?" disclosure.
- Consolidated the DKIM instructions that appeared both above and below
the DNS record table.
- Trimmed the workflow active-executions notice and the HTML-to-visual
editor warning to their load-bearing lists.
Deliberately untouched:
- packages/shared/src/i18n/locales/* and the public subscribe,
unsubscribe and manage pages. Their copy is translated into 16 locales
and editing English there would desync 15 of them.
- The onboarding flows and the dashboard greeting, whose copy is already
the voice everything else was moved toward.
- console.error strings, which are developer-facing.
PRODUCT.md replaces the legacy .impeccable.md, with the register made
explicit and a terminology section recording that user-facing nouns
(Contact, Event, Campaign, Workflow, Template, Segment) track the API.
Typecheck and production build pass.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
refactor(web): rewrite UX copy for clarity and consistency
…s--next--components--plunk
chore(next): release 0.14.0
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

13 participants

@vladbisceanu@driaug@mre@ReylanLugo@taniasanz7@jaschaio@andygrunwald@abehuman@pausan@hanamizuki@cyrilchandelier@tomfri@emanuelefaja