Skip to content

release(sofra): partner brand (S1 + S3a) — publishes images, does NOT deploy - #210

Merged
mahmutkaya merged 5 commits into
mainfrom
develop
Sep 2, 2026
Merged

mahmutkaya merged 5 commits into
mainfrom
develop

Conversation

@mahmutkaya

Copy link
Copy Markdown
Contributor

What ships

developmain, two PRs — the partner-brand slices, which the ROADMAP has been tracking as outstanding item #4 ("Sofra is 2 commits ahead of main").

  • #204 — S1: record a partner's public brand, published to nobody yet.
  • #205 — S3a: publish that brand into the registry entry, and refuse their legal name.

1730 insertions across the Prisma schema, four locale bundles, unit and e2e tests.

Read this before rolling it

Merging here does not deploy. build-image.yml publishes :latest and :migrate on main; the control plane is then rolled by hand on the box, which is the step that applies the migration.

That matters because this release carries prisma/migrations/20260830120000_partner_brand — a schema change against the control-plane database, which holds partner, billing and CRM records. So the sequence is: this merge publishes the images; someone rolls when they choose to, and the migration lands then.

I did not write or test this feature. It is released here because it is on the ROADMAP's own "Now" list as an outstanding release and because the merge itself deploys nothing. The CI on this PR — including its unit and e2e suites — is the evidence behind it, not a review by me. Take the hand-roll as a separate decision.

Blast radius of the merge itself

Images published to GHCR. sofrapiwas.com continues to serve 663fba9 until someone rolls it.

🤖 Generated with Claude Code

mahmutkaya and others added 3 commits August 30, 2026 17:24
…et (S1) (#204)

* feat(partner): record a partner's public brand, published to nobody yet (S1)

A PARTNER can enter and edit the public name and contact details their resold
restaurants could be credited to, and the founder can read them on /admin/partners/[id].

Nothing is published. No tenant site reads PartnerBrand; publishToTenants defaults
to false, is read by nothing, and the checkbox ships disabled with copy saying so —
the publishing half is gated on the owner decision in SOFRA-PARTNER-PLAN 11e.

The record is deliberately separate from BillingIdentity, which is the private legal
and tax record: for a sole trader its legalName is a natural person and its address
is a home address. prefillFromBillingIdentity carries across the trade name and
nothing else; renderableBrand() is the single choke point that returns null for an
unpublished brand.

* chore(partner): take the two SonarCloud smells on the brand slice

S7754: .filter(Boolean).length > 0 becomes a joined string tested for truthiness,
which the address line beside it already did.
S6582: !brand || !brand.publishToTenants becomes !brand?.publishToTenants — same
three cases (null, undefined, opted out), one expression.
… refuse their legal name (S3a) (#205)

* feat(partner): publish a partner's brand into the registry entry, and refuse their legal name (S3a)

`renderableBrand` is tightened to the D-B1 projection — `displayName` plus, when
there is one, `websiteUrl`, and nothing else — and now takes the partner's LEGAL
name as well, because D-B1a is a rule about the relationship between the two
records: a display name that IS the legal name is refused, compared normalised
(trim, case-fold, collapse whitespace). `prefillFromBillingIdentity` offers
`tradeName ?? legalName`, so a sole trader who saves the form untouched would
otherwise have their personal name published on a restaurant's public page by a
convenience nobody decided on.

Dropping tagline/email/phone/address/postcode/city/country from the projection is
a TIGHTENING, so it is self-announcing: the unit assertions that named those
fields went red and were rewritten deliberately. Widening back to a contact block
is the silent direction and needs its own controls plus a pii-inventory row.

`buildTenantRegistryEntry` emits `partner_name:` and `partner_url:` as flat keys
(§11d2 — provision-tenant.sh flattens each value with `str(v)`, so a nested map
would arrive in the shell as a stringified Python dict). It never emits
`partner_attribution:`: absent means true (D-B2), so an always-present key would
be a no-op line on every entry — it is the restaurant's switch, hand-added on
their behalf. ABSENCE IS THE CONTRACT, exactly as `base_domain` documents it, and
the proof is that every pre-existing test of that function passes unchanged.

The input field is typed as `renderableBrand`'s OUTPUT, so the choke point is the
only way a name can reach an entry, and it is filled in `openProvisioningPr`
rather than by each caller: the credit is derived from the slug — nobody types it
— so resolving it where the entry is written means no caller can forget it and
none can inject one. The lookup matches the slug through `Client.tenantSlug` OR
the reseller's plan, because the former is set by an admin only AFTER
provisioning; it fails open to no credit.

The form now says why, before the save: a billing record with no trade name gets
a note, and a display name echoing the legal name is called out as not
publishable while the save still succeeds — it is their record; what is refused
is publishing it. The provisioning PR body says when an entry carries a credit,
because that PR is the founder's review checkpoint (ADR-012) and it is the moment
someone should notice a name is about to become public.

Two files were split at the 200-LOC limit rather than baselined
(partner-brand-publish.ts, provisioning-pr-blocks.ts); both are in the coverage
floor's include list, so the split moved no code out of scope.

Plan: SOFRA-PARTNER-PLAN §11e, slice S3a.

* fix(a11y): use <output> for the not-published notice, not role=status (S6819)

SonarCloud's rule, and it is right: the native element carries the same live
region and is announced by assistive tech that does not implement the ARIA role.
The region itself is deliberate — the partner types and the verdict changes under
them, so it has to be spoken rather than merely rendered.
Release PR #210 is refused by the `main-develop` ruleset's strict up-to-date
rule: develop's tip does not contain main's tip. That is a commit-graph fact,
not a content one — every release merges develop→main as a merge commit, so
develop is BEHIND the moment a release lands and stays there until it is
back-merged.

Content-tested first, because "BEHIND" and "main holds something develop lacks"
are different claims:

  * no main-only files at all (`git diff --diff-filter=A` is empty)
  * `git merge-tree --write-tree` CLEAN
  * the only lines the diff attributes to main are develop's own refactors —
    main calls `buildTenantRegistryEntry(input)` where develop calls
    `buildTenantRegistryEntry({ ...input, partnerBrand })`, a strict superset,
    and the `"domains"` message key is present in all six locales on BOTH sides

So this merge exists to record ancestry and nothing else. The resulting tree is
identical to develop's, which is asserted below rather than assumed.

Merged as a real merge commit: a squash would keep the (empty) content change
and drop the ancestry link, leaving the next release to hit the same refusal.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
mahmutkaya and others added 2 commits September 2, 2026 19:25
`develop` has been red at 48091a5 — the commit this branch was cut from — with
three jobs failing on one cause. Trivy is named "misconfig + secrets" but was
reporting dependency CVEs, so it, OSV-Scanner and `npm audit (high+)` were all
naming the same packages:

  browserslist  4.28.4  CVE-2026-73088/73089  prototype pollution + unbounded
                                              memory growth      -> 4.28.7
  mysql2        3.15.3  GHSA-3f6p-5ww8-9rcr   auth-plugin downgrade leaks
                                              plaintext credentials -> 3.22.0
  fast-uri      3.1.5   4 advisories          SSRF + host confusion -> 3.1.6

`fast-uri` already had an override, at `^3.1.4`, which happily resolved to the
vulnerable 3.1.5 — a pin that exists is not the same as a pin that covers the
advisory.

`mysql2` arrives through `prisma@7.8.0`, whose CLI bundles a driver for every
database it supports. Sofra is Postgres and never executes it, but the merge
gate counts findings rather than judging them, and an unexecuted credential-leak
path is still a credential-leak path if the dependency graph ever shifts.

Fixed with `overrides` rather than direct dependencies: none of the three is a
direct dep, so declaring one would risk EOVERRIDE on any future spec mismatch.
Resolved to browserslist 4.28.8, mysql2 3.24.3, fast-uri 3.1.7 — `npm audit`
now reports 0 vulnerabilities.

Lockfile reconciled with `npx npm@10 install --package-lock-only` against the
existing lock rather than regenerated from scratch, so the linux-x64 optional
binaries CI needs survive: 58 before, 58 after. Verified with
`npx npm@10 ci --dry-run`, the only local command that runs what CI runs.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
chore: back-merge main into develop, and clear three high advisories reddening CI
@sonarqubecloud

sonarqubecloud Bot commented Sep 2, 2026

Copy link
Copy Markdown

@mahmutkaya
mahmutkaya merged commit cb701ac into main Sep 2, 2026
23 checks passed
mahmutkaya added a commit that referenced this pull request Sep 2, 2026
chore: back-merge the #210 release into develop
Sign up for free to 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.

1 participant