From 87bac9d4d27b3c095ccbc1eeda41dd9888c53dfe Mon Sep 17 00:00:00 2001 From: mahmutKaya <33642821+mahmutkaya@users.noreply.github.com> Date: Sun, 30 Aug 2026 17:24:37 +0200 Subject: [PATCH 1/3] feat(partner): record a partner's public brand, published to nobody yet (S1) (#204) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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. --- app/(control)/admin/partners/[id]/page.tsx | 52 +++++ app/(control)/dashboard/brand/page.tsx | 61 ++++++ app/(control)/dashboard/layout.tsx | 5 + components/control/PartnerBrandForm.tsx | 112 ++++++++++ lib/actions/partner-brand-actions.ts | 94 ++++++++ lib/partner-brand.ts | 178 +++++++++++++++ messages/ar.json | 30 ++- messages/de.json | 30 ++- messages/en.json | 30 ++- messages/fr.json | 30 ++- messages/nl.json | 30 ++- messages/tr.json | 30 ++- .../migration.sql | 52 +++++ prisma/schema.prisma | 64 ++++++ tests/e2e/helpers/db.ts | 42 ++++ tests/e2e/partner-brand.spec.ts | 117 ++++++++++ tests/unit/partner-brand.test.ts | 207 ++++++++++++++++++ vitest.config.ts | 6 + 18 files changed, 1164 insertions(+), 6 deletions(-) create mode 100644 app/(control)/dashboard/brand/page.tsx create mode 100644 components/control/PartnerBrandForm.tsx create mode 100644 lib/actions/partner-brand-actions.ts create mode 100644 lib/partner-brand.ts create mode 100644 prisma/migrations/20260830120000_partner_brand/migration.sql create mode 100644 tests/e2e/partner-brand.spec.ts create mode 100644 tests/unit/partner-brand.test.ts diff --git a/app/(control)/admin/partners/[id]/page.tsx b/app/(control)/admin/partners/[id]/page.tsx index ef0b2df..f6b4bf8 100644 --- a/app/(control)/admin/partners/[id]/page.tsx +++ b/app/(control)/admin/partners/[id]/page.tsx @@ -17,6 +17,10 @@ export default async function AdminPartnerDetailPage({ await requireAdmin(); const locale = await controlLocale(); const t = await getTranslations({ locale, namespace: "control.admin.partnerDetail" }); + // The partner-facing namespace, reused here on purpose: the founder should read + // the same words for these fields that the partner does, or the two pages drift + // into describing the same record differently (§11). + const tb = await getTranslations({ locale, namespace: "control.brand" }); const { id } = await params; const partner = await db.user.findFirst({ where: { id, role: "PARTNER" }, @@ -29,11 +33,27 @@ export default async function AdminPartnerDetailPage({ // surfaced rather than acted on — nothing auto-revokes (lib/base-domain- // verification.ts). This is where "is that still theirs?" gets asked. baseDomains: { orderBy: [{ verifiedAt: "desc" }, { createdAt: "desc" }] }, + // Read-only here (§11). The founder needs to see what a partner would be + // published AS — and, more to the point, whether they have ASKED to be + // published — before the owner decision in §11e is taken. Nothing on this + // page writes it: the record belongs to the partner. + brand: true, }, }); if (!partner) notFound(); const balance = partner.commissions.reduce((s, c) => s + c.amountCents, 0); + // Joined once each so an all-empty line renders nothing at all rather than a row + // of separators. + const contact = [partner.brand?.email, partner.brand?.phone].filter(Boolean).join(" · "); + const address = [ + partner.brand?.addressLine1, + partner.brand?.postalCode, + partner.brand?.city, + partner.brand?.countryCode, + ] + .filter(Boolean) + .join(" · "); // One clock for the whole render, so two domains never disagree about staleness. const now = new Date(); @@ -97,6 +117,38 @@ export default async function AdminPartnerDetailPage({ +
+

{tb("adminTitle")}

+ {partner.brand ? ( +
+ {partner.brand.displayName} + {partner.brand.tagline && ( + {partner.brand.tagline} + )} + {partner.brand.websiteUrl && ( + // `rel="noopener noreferrer"` because this is a partner-supplied + // address opened from an admin page; the https-only check at the write + // schema is the other half (lib/partner-brand.ts). + + {partner.brand.websiteUrl} + + )} + {contact && {contact}} + {address.length > 0 && {address}} + + {tb(partner.brand.publishToTenants ? "adminPublishAsked" : "adminPublishOff")} + +
+ ) : ( +

{tb("adminEmpty")}

+ )} +
+

{t("ledger")}

diff --git a/app/(control)/dashboard/brand/page.tsx b/app/(control)/dashboard/brand/page.tsx new file mode 100644 index 0000000..6835036 --- /dev/null +++ b/app/(control)/dashboard/brand/page.tsx @@ -0,0 +1,61 @@ +import { getTranslations } from "next-intl/server"; +import { requirePartner } from "@/lib/rbac"; +import { controlLocale } from "@/lib/control-locale"; +import { db } from "@/lib/db"; +import { prefillFromBillingIdentity } from "@/lib/partner-brand"; +import PartnerBrandForm from "@/components/control/PartnerBrandForm"; + +/** + * `/dashboard/brand` — the partner's own public details (SOFRA-PARTNER-PLAN §11). + * + * `requirePartner()`, not `requirePartnerOrOwner()`, for the reason + * `/dashboard/domains` states: a direct restaurant owner has one tenant and it is + * their own, so there is no third party for them to be credited as. + * + * Reads are scoped by `partner.id` on both queries — a partner can only ever load + * their own brand and their own billing identity. + * + * Never cached: it renders a row the same request may have just written, and the + * row is per-user. + */ +export const dynamic = "force-dynamic"; + +export default async function PartnerBrandPage() { + const partner = await requirePartner(); + const locale = await controlLocale(); + const t = await getTranslations({ locale, namespace: "control.brand" }); + + const brand = await db.partnerBrand.findUnique({ where: { partnerId: partner.id } }); + + // Only consulted when there is no brand yet, and only for a NAME. Looked up by + // `userId` — the simple link, which is the right one here: this page is about a + // PARTY, not about one plan, so the plan-scoped `resolveIdentityForPlan` would be + // answering a question nobody asked. Nothing from this record is stored by + // loading the page; the partner sees the value in an editable field and saves it + // themselves (lib/partner-brand.ts explains why only this one field crosses). + const prefill = brand + ? null + : prefillFromBillingIdentity( + await db.billingIdentity.findUnique({ + where: { userId: partner.id }, + select: { legalName: true, tradeName: true }, + }), + ); + + return ( +
+
+

{t("title")}

+

{t("intro")}

+

{t("privacyNote")}

+
+ +
+ {!brand && prefill && ( +

{t("prefillNote")}

+ )} + +
+
+ ); +} diff --git a/app/(control)/dashboard/layout.tsx b/app/(control)/dashboard/layout.tsx index c92b07f..0ab1396 100644 --- a/app/(control)/dashboard/layout.tsx +++ b/app/(control)/dashboard/layout.tsx @@ -48,6 +48,11 @@ export default async function DashboardLayout({ children }: { children: React.Re // let them claim their FIRST zone, so hiding it until they have one would hide // the only way to get one (SOFRA-PARTNER-FLEXIBILITY-PLAN D1). { href: "/dashboard/domains", label: t("nav.domains") }, + // Always present too, and for the same documented reason: the page's job is to + // let a partner enter their public details for the FIRST time, so gating the + // link on already having them would hide the only way to get them + // (SOFRA-PARTNER-PLAN §11). + { href: "/dashboard/brand", label: t("nav.brand") }, ]; if (hasBilling) { nav.push({ href: "/dashboard/billing", label: t("nav.plan") }); diff --git a/components/control/PartnerBrandForm.tsx b/components/control/PartnerBrandForm.tsx new file mode 100644 index 0000000..ed134bc --- /dev/null +++ b/components/control/PartnerBrandForm.tsx @@ -0,0 +1,112 @@ +"use client"; + +import { useActionState } from "react"; +import { useTranslations } from "next-intl"; +import { + savePartnerBrandAction, + type PartnerBrandState, +} from "@/lib/actions/partner-brand-actions"; +import ActionError from "./ActionError"; + +/** What the page hands in — either the stored row or, for a partner who has none + * yet, a prefill carrying nothing but a display name. */ +export type BrandDefaults = { + displayName: string; + tagline?: string | null; + websiteUrl?: string | null; + email?: string | null; + phone?: string | null; + addressLine1?: string | null; + postalCode?: string | null; + city?: string | null; + countryCode?: string | null; + publishToTenants?: boolean; +}; + +type TextField = Exclude; + +/** + * A partner's PUBLIC details (SOFRA-PARTNER-PLAN §11). + * + * Bound directly to the server action so it also works as a plain form POST with + * no JavaScript (CLAUDE.md §3). + * + * Everything here is typed by hand, including the address, and that is the + * design rather than an oversight: the control plane already holds an address for + * this partner, in `BillingIdentity`, and for a sole trader it is their home. + * Only `displayName` may be prefilled, from the TRADE name (lib/partner-brand.ts). + */ +export default function PartnerBrandForm({ + defaults, +}: Readonly<{ defaults?: BrandDefaults }>) { + const t = useTranslations("control.brand"); + const [state, action, pending] = useActionState( + savePartnerBrandAction, + {}, + ); + + const field = ( + name: TextField, + opts: { required?: boolean; type?: string; maxLength?: number } = {}, + ) => ( + + ); + + return ( +
+ {field("displayName", { required: true, maxLength: 80 })} + {field("tagline", { maxLength: 120 })} + {field("websiteUrl", { type: "url", maxLength: 200 })} + {field("email", { type: "email" })} + {field("phone", { maxLength: 40 })} + {field("addressLine1")} + {field("postalCode", { maxLength: 20 })} + {field("city", { maxLength: 120 })} + {field("countryCode", { maxLength: 2 })} + + {/* DISABLED on purpose, and the note says why. Nothing consumes + `publishToTenants` yet (§11e is an open owner decision), and a switch + that a partner can flip while nothing changes is a lie told to the one + person entitled to decide this. The column, the action and + `renderableBrand()` are all here, so enabling it later is this one + attribute. A disabled checkbox is not submitted, which the action already + reads as false. */} +
+ +

{t("publishMeaning")}

+

{t("publishUnavailable")}

+
+ +
+ + {state.ok && ( + + {t("saved")} + + )} + +
+
+ ); +} diff --git a/lib/actions/partner-brand-actions.ts b/lib/actions/partner-brand-actions.ts new file mode 100644 index 0000000..a10849f --- /dev/null +++ b/lib/actions/partner-brand-actions.ts @@ -0,0 +1,94 @@ +"use server"; + +// A partner records the public brand their clients' guests may be shown +// (SOFRA-PARTNER-PLAN §11). +// +// This slice STORES and EDITS. It publishes nothing: no tenant site reads +// `PartnerBrand`, and `publishToTenants` is an intent flag whose only future +// reader is `renderableBrand()` (§11e — the owner gate). The write path is built +// now so that the day publishing is switched on, the details it publishes are +// ones the partner typed knowing they were public — rather than the legal record, +// which is a person's own name and home address for a sole trader. + +import { revalidatePath } from "next/cache"; +import { requirePartner } from "@/lib/rbac"; +import { db } from "@/lib/db"; +import { audit } from "@/lib/audit"; +import { rateLimit } from "@/lib/rate-limit"; +import { checkboxOn, partnerBrandSchema } from "@/lib/partner-brand"; + +/** `error` is a message key in `control.errors` (rendered by ); + * Zod issue messages pass through raw, as elsewhere in this directory. */ +export type PartnerBrandState = { error?: string; ok?: boolean }; + +export async function savePartnerBrandAction( + _prev: PartnerBrandState, + formData: FormData, +): Promise { + // `requirePartner()`, not `requirePartnerOrOwner()`: a brand is what a RESELLER + // shows on the restaurants they sell. A direct restaurant OWNER has one tenant, + // which is their own — there is no third party for them to be credited as, so + // this surface has nothing to offer them and they are bounced to /dashboard. + const partner = await requirePartner(); + + // Per `user.id` rather than per IP, matching the sibling partner actions: the + // actor is authenticated, so their id has no NAT collisions and no spoofable + // proxy header behind it. + if (!rateLimit(`partner-brand:${partner.id}`, 30, 15 * 60 * 1000)) { + return { error: "tooManyAttempts" }; + } + + const parsed = partnerBrandSchema.safeParse({ + ...Object.fromEntries(formData), + // A checkbox is absent from FormData when unticked, so it can never be read + // straight off the payload as a boolean. Anything that is not "on"/"true" is + // off — the safe direction for a flag whose true value means "show this to + // the public". + publishToTenants: checkboxOn(formData.get("publishToTenants")), + }); + if (!parsed.success) return { error: parsed.error.issues[0]?.message ?? "invalidInput" }; + const input = parsed.data; + + // An emptied field must be CLEARED, not left alone. The schema normalises a + // blank input to `undefined` (there is no "" state to store), and Prisma reads + // `undefined` in an update as "do not touch this column" — so passing the + // parsed object straight through would make deleting a tagline impossible: the + // partner would save, be told it worked, and reload to find the old line still + // there. `null` is the write that means "gone". + const data = { + displayName: input.displayName, + tagline: input.tagline ?? null, + websiteUrl: input.websiteUrl ?? null, + email: input.email ?? null, + phone: input.phone ?? null, + addressLine1: input.addressLine1 ?? null, + postalCode: input.postalCode ?? null, + city: input.city ?? null, + countryCode: input.countryCode ?? null, + publishToTenants: input.publishToTenants, + }; + + // The key comes from the SESSION and is never read from the payload. A + // `partnerId` field in the FormData would be an IDOR: any logged-in partner + // could rewrite another's public details, which is both a takeover of their + // identity and — once publishing exists — a way to put text on someone else's + // restaurant pages. `partnerId` is written LAST so that no spread can shadow + // it, even if the schema were later loosened to pass unknown keys through. + const row = await db.partnerBrand.upsert({ + where: { partnerId: partner.id }, + create: { ...data, partnerId: partner.id }, + update: data, + }); + + // No brand FIELD is logged. They are contact details of a real company and, in + // the sole-trader case, of a real person (CLAUDE.md §5.8 — no PII in logs). What + // the audit row needs is that the record was written and whether the partner + // asked for it to be public; the values themselves are one `SELECT` away for + // anyone entitled to read them. + await audit(partner.id, "partner.brand.saved", "PartnerBrand", row.partnerId, { + publishToTenants: row.publishToTenants, + }); + + revalidatePath("/dashboard/brand"); + return { ok: true }; +} diff --git a/lib/partner-brand.ts b/lib/partner-brand.ts new file mode 100644 index 0000000..77af13f --- /dev/null +++ b/lib/partner-brand.ts @@ -0,0 +1,178 @@ +// A partner's PUBLIC brand — the pure rules about it (SOFRA-PARTNER-PLAN §11). +// +// Pure by construction: no `db` import, no I/O, nothing that can read a session. +// Everything here is either "is this input acceptable" or "may this record be +// shown", and both have to be answerable — and unit-testable — without a +// database. +// +// The one idea the whole module exists to protect: **a public record and a legal +// record are different records.** `BillingIdentity` holds the name a member state +// registered and the address an invoice is posted to; for a sole trader those are +// a person and their home. This holds what a diner may be shown. Nothing copies +// the first into the second except `prefillFromBillingIdentity`, which carries one +// field and says why. + +import { z } from "zod"; +import { isAssignedCountryCode } from "@/lib/country-code"; + +/** + * An empty form field is ABSENT, not the empty string. + * + * A browser posts every text input it renders, so an untouched optional field + * arrives as `""`. Stored verbatim that would make `tagline: ""` and `tagline: + * null` two spellings of the same state, and every future reader would have to + * know both — the one that forgot would render an empty line in a footer. + */ +const blankToUndefined = (value: unknown): unknown => + typeof value === "string" && value.trim() === "" ? undefined : value; + +const optionalText = (max: number) => + z.preprocess(blankToUndefined, z.string().trim().max(max).optional()); + +/** + * `https://` and nothing else. + * + * This value ends up in an `href` on a page served to the public, so the scheme + * is the security boundary, not a formatting preference. `javascript:` is script + * execution in the reader's page; `http:` is a downgrade we would be advertising + * on someone else's site. A bare host (`example.com`) is refused rather than + * silently prefixed: guessing a scheme for a partner is exactly how `http` gets + * published by accident, and the form asks for the full address. + * + * `URL` does the parsing, so no regex has to be right about hosts. + */ +function isHttpsUrl(value: string): boolean { + try { + return new URL(value).protocol === "https:"; + } catch { + return false; + } +} + +/** + * What a partner must supply to record a public brand. + * + * `displayName` is the only required field, and deliberately so: it is the only + * one that has to exist for the record to mean anything, and demanding an address + * or a phone number would push a partner into typing their private ones back in. + * + * `countryCode` is checked for MEMBERSHIP of ISO 3166-1 alpha-2 through the SAME + * helper the billing schema uses (lib/country-code.ts) — one list, not two. It + * decides nothing about tax here (it is only ever displayed), but a second, + * drifting copy of the country list is the kind of thing that later gets consulted + * by something that does. + */ +export const partnerBrandSchema = z.object({ + displayName: z.string().trim().min(1).max(80), + tagline: optionalText(120), + websiteUrl: z.preprocess( + blankToUndefined, + z + .string() + .trim() + .max(200) + .refine(isHttpsUrl, "Use a full address starting with https://") + .optional(), + ), + email: z.preprocess(blankToUndefined, z.string().trim().max(200).email().optional()), + phone: optionalText(40), + addressLine1: optionalText(200), + postalCode: optionalText(20), + city: optionalText(120), + countryCode: z.preprocess( + blankToUndefined, + z + .string() + .trim() + .toUpperCase() + .refine(isAssignedCountryCode, "2-letter ISO 3166-1 country code, e.g. CH") + .optional(), + ), + publishToTenants: z.boolean(), +}); + +export type PartnerBrandInput = z.infer; + +/** A checkbox is present-or-absent in FormData; anything else is off. Kept beside + * the schema so the form and the action cannot disagree about what "on" means. */ +export function checkboxOn(value: unknown): boolean { + return value === "on" || value === "true"; +} + +/** The subset of a billing identity a prefill may read. Structural rather than the + * Prisma type, so the tests need no generated client. */ +export type IdentityNames = { legalName: string; tradeName: string | null }; + +/** + * The one field that may be carried from the legal record into the public one. + * + * `tradeName ?? legalName` and NOTHING ELSE, as a saving of typing on the field + * that is least likely to be private — a trade name is chosen to be shown. It is + * still only a DEFAULT in an editable input; the partner sees it before it is + * stored and can replace it. + * + * The address, the registration number and the VAT number are deliberately not + * here. Copying them would be the exact mistake the two-model split exists to + * prevent: for a sole trader the billing address is a home address and + * `legalName` is a natural person, and a prefilled field is one that gets saved + * without being read. The partner types the rest by hand, knowing it is public, + * and that act of typing IS the consent. + * + * The `legalName` fallback is the one concession, and it is bounded: a partner + * with no trade name sees their own name in a visible, editable field labelled + * as the public one, not silently published. + */ +export function prefillFromBillingIdentity( + identity: IdentityNames | null | undefined, +): { displayName: string } | null { + const name = identity?.tradeName?.trim() || identity?.legalName?.trim(); + return name ? { displayName: name } : null; +} + +/** What is stored. Structural, for the same reason as `IdentityNames`. */ +export type StoredBrand = { + displayName: string; + tagline: string | null; + websiteUrl: string | null; + email: string | null; + phone: string | null; + addressLine1: string | null; + postalCode: string | null; + city: string | null; + countryCode: string | null; + publishToTenants: boolean; +}; + +/** Exactly the fields a public surface may render. */ +export type RenderableBrand = Omit; + +/** + * The ONLY way an unpublished brand can reach a public surface: it cannot. + * + * A single choke point on purpose. The alternative — every future caller + * remembering to test `publishToTenants` before it renders — is a rule that holds + * until the first caller that forgets, and the failure is silent: a partner who + * never opted in appears in a footer and nothing goes red. Here, forgetting means + * calling this function and getting `null`. + * + * It also RE-PROJECTS rather than spreading the row, so a column added to + * PartnerBrand later (a founder note, an internal flag) is not published by the + * mere fact of having been added. + * + * Nothing consumes this yet — the publishing half is gated on an owner decision. + * It ships now so that when something does, the gate is already the only door. + */ +export function renderableBrand(brand: StoredBrand | null | undefined): RenderableBrand | null { + if (!brand?.publishToTenants) return null; + return { + displayName: brand.displayName, + tagline: brand.tagline, + websiteUrl: brand.websiteUrl, + email: brand.email, + phone: brand.phone, + addressLine1: brand.addressLine1, + postalCode: brand.postalCode, + city: brand.city, + countryCode: brand.countryCode, + }; +} diff --git a/messages/ar.json b/messages/ar.json index 4144db0..b641dbf 100644 --- a/messages/ar.json +++ b/messages/ar.json @@ -482,7 +482,8 @@ "invoices": "الفواتير", "icp": "ICP", "billingDetails": "بيانات الفوترة", - "domains": "النطاقات" + "domains": "النطاقات", + "brand": "العلامة" }, "groups": { "pipeline": "المسار", @@ -1233,6 +1234,33 @@ "sending": "جارٍ الإرسال…", "sent": "تم الإرسال — سنعود إليك." }, + "brand": { + "title": "بياناتك العامة", + "intro": "الاسم وبيانات الاتصال التي قد يراها ضيوف المطعم بوصفها الشركة التي تقف خلف موقعه. تكتبها هنا حتى لا يُنشر سوى ما تختاره أنت.", + "privacyNote": "هذه البيانات منفصلة عمداً عن بيانات الفوترة. يبقى عنوان الفاتورة ورقم السجل خاصين ولا يُعرضان لأحد غيرنا.", + "prefillNote": "ملأنا اسمك التجاري للبدء. لا يُنسخ أي شيء آخر من بيانات الفوترة — اكتب ما توافق على أن يراه الضيوف.", + "fields": { + "displayName": "الاسم المعروض", + "tagline": "شعار قصير (اختياري)", + "websiteUrl": "الموقع الإلكتروني (https://…)", + "email": "البريد الإلكتروني للتواصل", + "phone": "الهاتف", + "addressLine1": "العنوان", + "postalCode": "الرمز البريدي", + "city": "المدينة", + "countryCode": "الدولة (حرفان)" + }, + "publishLabel": "اعرض هذه البيانات في تذييل كل موقع مطعم تبيعه.", + "publishMeaning": "سيراها ضيوف عملائك.", + "publishUnavailable": "غير متاح بعد — يبقى النشر معطّلاً إلى أن يفعّله مالك المنصة. تُحفظ بياناتك في كل الأحوال.", + "save": "حفظ", + "saving": "جارٍ الحفظ…", + "saved": "تم الحفظ.", + "adminTitle": "علامته التجارية العامة", + "adminEmpty": "لم تُدخل أي بيانات عامة.", + "adminPublishAsked": "طلب أن يظهر على مواقع مطاعمه.", + "adminPublishOff": "لم يطلب الظهور العلني." + }, "baseDomain": { "title": "نطاقاتك الخاصة", "intro": "ضَع المطاعم التي تبيعها تحت نطاق خاص بك — obresse.yourcompany.com بدلاً من obresse.sofrapiwas.com. أضِف النطاق هنا وأثبت ملكيته؛ بعدها يُعرض عليك مع كل عميل جديد.", diff --git a/messages/de.json b/messages/de.json index ab6fd5b..9e5d56f 100644 --- a/messages/de.json +++ b/messages/de.json @@ -482,7 +482,8 @@ "invoices": "Rechnungen", "icp": "ICP", "billingDetails": "Rechnungsdaten", - "domains": "Domains" + "domains": "Domains", + "brand": "Marke" }, "groups": { "pipeline": "Pipeline", @@ -1233,6 +1234,33 @@ "sending": "Senden…", "sent": "Gesendet — wir melden uns." }, + "brand": { + "title": "Ihre öffentlichen Angaben", + "intro": "Der Name und die Kontaktdaten, die Gäste eines Restaurants als das Unternehmen hinter der Website sehen könnten. Sie geben sie hier ein, damit nur das öffentlich wird, was Sie auswählen.", + "privacyNote": "Sie sind bewusst von Ihren Rechnungsdaten getrennt. Ihre Rechnungsadresse und Ihre Registernummer bleiben privat und werden niemandem außer uns gezeigt.", + "prefillNote": "Wir haben Ihren Handelsnamen als Anfang eingetragen. Sonst wird nichts aus Ihren Rechnungsdaten übernommen — bitte geben Sie ein, was Gäste sehen dürfen.", + "fields": { + "displayName": "Anzeigename", + "tagline": "Slogan (optional)", + "websiteUrl": "Website (https://…)", + "email": "Kontakt-E-Mail", + "phone": "Telefon", + "addressLine1": "Adresse", + "postalCode": "Postleitzahl", + "city": "Stadt", + "countryCode": "Land (2 Buchstaben)" + }, + "publishLabel": "Diese Angaben in der Fußzeile jeder Restaurant-Website zeigen, die Sie weiterverkaufen.", + "publishMeaning": "Die Gäste Ihrer Kunden sehen sie.", + "publishUnavailable": "Noch nicht verfügbar — die Veröffentlichung bleibt aus, bis der Plattformbetreiber sie freigibt. Ihre Angaben werden in jedem Fall gespeichert.", + "save": "Speichern", + "saving": "Speichern…", + "saved": "Gespeichert.", + "adminTitle": "Ihre öffentliche Marke", + "adminEmpty": "Keine öffentlichen Angaben eingegeben.", + "adminPublishAsked": "Möchte auf den Websites der eigenen Restaurants gezeigt werden.", + "adminPublishOff": "Hat nicht darum gebeten, öffentlich gezeigt zu werden." + }, "baseDomain": { "title": "Ihre eigenen Domains", "intro": "Bringen Sie die Restaurants, die Sie verkaufen, unter eine eigene Domain — obresse.ihrefirma.com statt obresse.sofrapiwas.com. Domain hier hinzufügen und nachweisen, dass sie Ihnen gehört; danach wird sie bei jedem neuen Kunden angeboten.", diff --git a/messages/en.json b/messages/en.json index 4800792..1dcd5fb 100644 --- a/messages/en.json +++ b/messages/en.json @@ -482,7 +482,8 @@ "invoices": "Invoices", "icp": "ICP", "billingDetails": "Billing details", - "domains": "Domains" + "domains": "Domains", + "brand": "Brand" }, "groups": { "pipeline": "Pipeline", @@ -1233,6 +1234,33 @@ "sending": "Sending…", "sent": "Sent — we'll come back to you." }, + "brand": { + "title": "Your public details", + "intro": "The name and contact details a restaurant's guests could be shown as the company behind their website. You type them here, so only what you choose is ever public.", + "privacyNote": "These are kept separate from your billing details on purpose. Your invoice address and registration number stay private and are never shown to anyone but us.", + "prefillNote": "We have filled in your trading name to start you off. Nothing else is copied from your billing details — please type what you are happy for guests to see.", + "fields": { + "displayName": "Display name", + "tagline": "Tagline (optional)", + "websiteUrl": "Website (https://…)", + "email": "Contact email", + "phone": "Phone", + "addressLine1": "Address", + "postalCode": "Postcode", + "city": "City", + "countryCode": "Country (2 letters)" + }, + "publishLabel": "Show these details in the footer of every restaurant site you resell.", + "publishMeaning": "Your customers' guests will see them.", + "publishUnavailable": "Not available yet — publishing stays off until the platform owner enables it. Your details are saved either way.", + "save": "Save", + "saving": "Saving…", + "saved": "Saved.", + "adminTitle": "Their public brand", + "adminEmpty": "No public details entered.", + "adminPublishAsked": "Asked to be shown on their restaurants' sites.", + "adminPublishOff": "Has not asked to be shown publicly." + }, "baseDomain": { "title": "Your own domains", "intro": "Put the restaurants you sell under a domain of your own — obresse.yourcompany.com instead of obresse.sofrapiwas.com. Add the domain here and prove it is yours; from then on it is offered every time you set up a new client.", diff --git a/messages/fr.json b/messages/fr.json index 201d2e5..5e592a8 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -482,7 +482,8 @@ "invoices": "Factures", "icp": "ICP", "billingDetails": "Infos de facturation", - "domains": "Domaines" + "domains": "Domaines", + "brand": "Marque" }, "groups": { "pipeline": "Pipeline", @@ -1233,6 +1234,33 @@ "sending": "Envoi…", "sent": "Envoyé — nous revenons vers vous." }, + "brand": { + "title": "Vos coordonnées publiques", + "intro": "Le nom et les coordonnées que les clients d'un restaurant pourraient voir comme l'entreprise derrière leur site. Vous les saisissez ici, afin que seul ce que vous choisissez soit public.", + "privacyNote": "Elles sont volontairement distinctes de vos données de facturation. Votre adresse de facturation et votre numéro d'enregistrement restent privés et ne sont montrés à personne d'autre que nous.", + "prefillNote": "Nous avons prérempli votre nom commercial pour commencer. Rien d'autre n'est copié depuis vos données de facturation — saisissez ce que vous acceptez de montrer aux clients.", + "fields": { + "displayName": "Nom affiché", + "tagline": "Slogan (facultatif)", + "websiteUrl": "Site web (https://…)", + "email": "E-mail de contact", + "phone": "Téléphone", + "addressLine1": "Adresse", + "postalCode": "Code postal", + "city": "Ville", + "countryCode": "Pays (2 lettres)" + }, + "publishLabel": "Afficher ces coordonnées dans le pied de page de chaque site de restaurant que vous revendez.", + "publishMeaning": "Les clients de vos restaurants les verront.", + "publishUnavailable": "Pas encore disponible — la publication reste désactivée jusqu'à ce que le propriétaire de la plateforme l'active. Vos coordonnées sont enregistrées dans tous les cas.", + "save": "Enregistrer", + "saving": "Enregistrement…", + "saved": "Enregistré.", + "adminTitle": "Sa marque publique", + "adminEmpty": "Aucune coordonnée publique saisie.", + "adminPublishAsked": "A demandé à être affiché sur les sites de ses restaurants.", + "adminPublishOff": "N'a pas demandé à être affiché publiquement." + }, "baseDomain": { "title": "Vos propres domaines", "intro": "Placez les restaurants que vous vendez sous un domaine qui vous appartient — obresse.votresociete.com au lieu de obresse.sofrapiwas.com. Ajoutez le domaine ici et prouvez qu'il est à vous ; il vous sera ensuite proposé à chaque nouveau client.", diff --git a/messages/nl.json b/messages/nl.json index 9978180..9749f3c 100644 --- a/messages/nl.json +++ b/messages/nl.json @@ -482,7 +482,8 @@ "invoices": "Facturen", "icp": "ICP", "billingDetails": "Factuurgegevens", - "domains": "Domeinen" + "domains": "Domeinen", + "brand": "Merk" }, "groups": { "pipeline": "Pijplijn", @@ -1233,6 +1234,33 @@ "sending": "Versturen…", "sent": "Verstuurd — we komen bij je terug." }, + "brand": { + "title": "Uw publieke gegevens", + "intro": "De naam en contactgegevens die gasten van een restaurant kunnen zien als het bedrijf achter hun website. U typt ze hier, zodat alleen wat u kiest openbaar wordt.", + "privacyNote": "Deze staan bewust los van uw factuurgegevens. Uw factuuradres en registratienummer blijven privé en worden aan niemand anders dan ons getoond.", + "prefillNote": "We hebben uw handelsnaam alvast ingevuld. Verder wordt er niets uit uw factuurgegevens overgenomen — typ wat u gasten wilt laten zien.", + "fields": { + "displayName": "Weergavenaam", + "tagline": "Slogan (optioneel)", + "websiteUrl": "Website (https://…)", + "email": "Contact-e-mail", + "phone": "Telefoon", + "addressLine1": "Adres", + "postalCode": "Postcode", + "city": "Plaats", + "countryCode": "Land (2 letters)" + }, + "publishLabel": "Toon deze gegevens in de voettekst van elke restaurantsite die u doorverkoopt.", + "publishMeaning": "De gasten van uw klanten zien ze.", + "publishUnavailable": "Nog niet beschikbaar — publiceren blijft uit tot de eigenaar van het platform het inschakelt. Uw gegevens worden hoe dan ook bewaard.", + "save": "Opslaan", + "saving": "Opslaan…", + "saved": "Opgeslagen.", + "adminTitle": "Hun publieke merk", + "adminEmpty": "Geen publieke gegevens ingevuld.", + "adminPublishAsked": "Wil getoond worden op de sites van hun restaurants.", + "adminPublishOff": "Heeft niet gevraagd om publiek getoond te worden." + }, "baseDomain": { "title": "Je eigen domeinen", "intro": "Zet de restaurants die je verkoopt onder een eigen domein — obresse.jouwbedrijf.com in plaats van obresse.sofrapiwas.com. Voeg het domein hier toe en bewijs dat het van jou is; daarna wordt het aangeboden bij elke nieuwe klant.", diff --git a/messages/tr.json b/messages/tr.json index e5ce338..82bdf75 100644 --- a/messages/tr.json +++ b/messages/tr.json @@ -482,7 +482,8 @@ "invoices": "Faturalar", "icp": "ICP", "billingDetails": "Fatura bilgileri", - "domains": "Alan adları" + "domains": "Alan adları", + "brand": "Marka" }, "groups": { "pipeline": "Süreç", @@ -1233,6 +1234,33 @@ "sending": "Gönderiliyor…", "sent": "Gönderildi — size döneceğiz." }, + "brand": { + "title": "Herkese açık bilgileriniz", + "intro": "Bir restoranın misafirlerinin, sitelerinin arkasındaki şirket olarak görebileceği ad ve iletişim bilgileri. Yalnızca seçtikleriniz herkese açık olsun diye bunları burada siz yazarsınız.", + "privacyNote": "Bunlar fatura bilgilerinizden bilerek ayrı tutulur. Fatura adresiniz ve sicil numaranız gizli kalır ve bizden başka kimseye gösterilmez.", + "prefillNote": "Başlangıç olarak ticari adınızı doldurduk. Fatura bilgilerinizden başka hiçbir şey kopyalanmaz — misafirlerin görmesini istediğiniz bilgileri yazın.", + "fields": { + "displayName": "Görünen ad", + "tagline": "Slogan (isteğe bağlı)", + "websiteUrl": "Web sitesi (https://…)", + "email": "İletişim e-postası", + "phone": "Telefon", + "addressLine1": "Adres", + "postalCode": "Posta kodu", + "city": "Şehir", + "countryCode": "Ülke (2 harf)" + }, + "publishLabel": "Bu bilgileri, sattığınız her restoran sitesinin alt bilgisinde gösterin.", + "publishMeaning": "Müşterilerinizin misafirleri bunları görecek.", + "publishUnavailable": "Henüz kullanılamıyor — platform sahibi etkinleştirene kadar yayınlama kapalı kalır. Bilgileriniz yine de kaydedilir.", + "save": "Kaydet", + "saving": "Kaydediliyor…", + "saved": "Kaydedildi.", + "adminTitle": "Herkese açık markası", + "adminEmpty": "Herkese açık bilgi girilmemiş.", + "adminPublishAsked": "Restoranlarının sitelerinde gösterilmek istiyor.", + "adminPublishOff": "Herkese açık gösterilmeyi istemedi." + }, "baseDomain": { "title": "Kendi alan adlarınız", "intro": "Sattığınız restoranları kendi alan adınızın altına alın — obresse.sofrapiwas.com yerine obresse.sirketiniz.com. Alan adını buraya ekleyin ve size ait olduğunu kanıtlayın; sonrasında her yeni müşteride seçenek olarak sunulur.", diff --git a/prisma/migrations/20260830120000_partner_brand/migration.sql b/prisma/migrations/20260830120000_partner_brand/migration.sql new file mode 100644 index 0000000..21aa7d2 --- /dev/null +++ b/prisma/migrations/20260830120000_partner_brand/migration.sql @@ -0,0 +1,52 @@ +-- The public face of a partner (workspace docs/plans/SOFRA-PARTNER-PLAN.md §11). +-- +-- A partner asked to appear in the footer of the restaurant sites they resell. +-- The control plane already held company data about them — but ONLY as +-- `BillingIdentity`, which is the private legal and tax record: the registered +-- name, the invoice address, a registration number, a VAT number. For a sole +-- trader (the shape of the first reseller) that name is a NATURAL PERSON and +-- that address is where they live. Reusing it would have published someone's +-- home address on a public website in order to save them typing. +-- +-- So this is a SECOND, PUBLIC record, entered by hand, never joined to the +-- first. Only what is typed here can ever be shown. The two tables stay apart so +-- that publishing a brand cannot publish an invoice address. +-- +-- `publishToTenants` DEFAULT FALSE, and nothing reads it yet: the publishing +-- half is gated on an owner decision about what a diner may be told about a +-- third party. The column records the partner's intent rather than leaving it to +-- be inferred later, and the app renders the switch disabled until there is +-- something to switch on. +-- +-- ADDITIVE AND SAFE ON A DB WITH LIVE ROWS: one new table, no column added to, +-- renamed on, or dropped from any existing one, no backfill, no data rewrite. +-- Nothing reads it until the surfaces in this PR do, so applying it before +-- rolling the app is a no-op for every running query plan. +-- +-- PRIMARY KEY is `partnerId` itself, not a surrogate id: a partner has exactly +-- one public brand, and the identity of the row IS the partner. That is also what +-- makes the write an `upsert` on a key the server takes from the session, so no +-- request can name the row it edits. + +CREATE TABLE "PartnerBrand" ( + "partnerId" TEXT NOT NULL, + "displayName" TEXT NOT NULL, + "tagline" TEXT, + "websiteUrl" TEXT, + "email" TEXT, + "phone" TEXT, + "addressLine1" TEXT, + "postalCode" TEXT, + "city" TEXT, + "countryCode" TEXT, + "publishToTenants" BOOLEAN NOT NULL DEFAULT false, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "PartnerBrand_pkey" PRIMARY KEY ("partnerId") +); + +-- CASCADE: a brand is meaningless without the partner it belongs to, and a +-- departed partner's public details must not outlive their account. +ALTER TABLE "PartnerBrand" ADD CONSTRAINT "PartnerBrand_partnerId_fkey" + FOREIGN KEY ("partnerId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index a5abf3b..cbfbe04 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -90,6 +90,9 @@ model User { billingsPaid TenantBilling[] @relation("BillingPayer") // Base domains a PARTNER has claimed for their own clients (D1). baseDomains PartnerDomain[] + // The PUBLIC brand a PARTNER may show to their clients' guests (§11). Kept + // apart from `billingIdentity` below on purpose — see PartnerBrand. + brand PartnerBrand? // The legal entity this user is invoiced as (B1). One per user: a party has // one set of registration details at a time. billingIdentity BillingIdentity? @@ -346,6 +349,67 @@ model PartnerDomain { @@index([partnerId]) } +// The PUBLIC face of a partner — what a diner may be shown +// (workspace docs/plans/SOFRA-PARTNER-PLAN.md §11, slice S1). +// +// This is a DIFFERENT THING from `BillingIdentity`, which sits a hundred lines +// above it, and the separation is the entire point of the model rather than a +// tidiness preference. BillingIdentity is the PRIVATE legal and tax record: the +// name a member state registered, the address an invoice is posted to, a +// registration number, a VAT number. For a sole trader — the shape of the first +// reseller — `legalName` is a NATURAL PERSON and `addressLine1` is where that +// person lives. Publishing it because it happened to be the only company data we +// held would put someone's home address in the footer of a restaurant website. +// +// So a partner types their public details a SECOND time, deliberately, and the +// two records are never joined. A field can only ever become public by being +// entered here. `prefillFromBillingIdentity` (lib/partner-brand.ts) carries +// across exactly ONE field, the trade name, and documents why it carries nothing +// else. +// +// Its own model rather than columns on PartnerProfile, for the reason +// PartnerDomain states below: PartnerProfile is a founder-facing CRM row that +// carries `notes`, which are never rendered to the partner — a founder-facing +// row is the wrong home for a field whose audience is the public. +// +// `publishToTenants` defaults to FALSE and **NOTHING CONSUMES IT YET.** No tenant +// site reads this table; the publishing half is gated on an owner decision about +// what a restaurant's guests may be shown about a third party. The column exists +// so the partner's intent is recorded rather than inferred later, and the single +// reader that will ever exist is `renderableBrand()`. +model PartnerBrand { + partnerId String @id + partner User @relation(fields: [partnerId], references: [id], onDelete: Cascade) + + /// The brand as a DINER reads it ("Solution Eva"). Not BillingIdentity.legalName, + /// which for a sole trader is a person's own name. + displayName String + /// One line under the name. Optional; most partners will not want one. + tagline String? + /// `https://` only — see lib/partner-brand.ts. A public link that a tenant page + /// would render, so the scheme is validated rather than assumed. + websiteUrl String? + + /// Contact a guest may be given. A WORK address by intent: the partner typed it + /// here knowing it is public, which is precisely what the billing record's + /// equivalents were never told. + email String? + phone String? + addressLine1 String? + postalCode String? + city String? + /// ISO 3166-1 alpha-2, UPPERCASE when present. Same membership check as the + /// billing record (lib/country-code.ts), but it decides nothing about tax here — + /// it is only ever displayed. + countryCode String? + + /// Opt-in, off by default, and READ BY NOTHING TODAY. See the note above. + publishToTenants Boolean @default(false) + + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt +} + model Client { id String @id @default(cuid()) partnerId String diff --git a/tests/e2e/helpers/db.ts b/tests/e2e/helpers/db.ts index ecd13df..cbe87f2 100644 --- a/tests/e2e/helpers/db.ts +++ b/tests/e2e/helpers/db.ts @@ -706,3 +706,45 @@ export async function findBackupJobs(tenantSlug: string): Promise { + const rows = await query<{ displayName: string; city: string | null; publish: boolean }>( + `SELECT "displayName", city, "publishToTenants" AS publish + FROM "PartnerBrand" WHERE "partnerId" = $1`, + [partnerId], + ); + return rows[0] ?? null; +} + +/** An existing brand, so the edit path is exercised rather than the create path. */ +export async function arrangePartnerBrand( + partnerId: string, + displayName: string, +): Promise { + await query( + `INSERT INTO "PartnerBrand" ("partnerId", "displayName", "updatedAt") + VALUES ($1, $2, now()) + ON CONFLICT ("partnerId") DO UPDATE SET "displayName" = EXCLUDED."displayName"`, + [partnerId, displayName], + ); +} + +/** The legal record, so the prefill can be shown to carry the TRADE name and + * nothing else — the address below is what must never appear on the brand page. */ +export async function arrangeBillingIdentityFor( + userId: string, + opts: { legalName: string; tradeName?: string | null; addressLine1: string }, +): Promise { + await query( + `INSERT INTO "BillingIdentity" + (id, "userId", "legalName", "tradeName", "addressLine1", "postalCode", city, + "countryCode", "billingEmail", "updatedAt") + VALUES (gen_random_uuid()::text, $1, $2, $3, $4, '1204', 'Genève', 'CH', + 'billing@example.com', now())`, + [userId, opts.legalName, opts.tradeName ?? null, opts.addressLine1], + ); +} diff --git a/tests/e2e/partner-brand.spec.ts b/tests/e2e/partner-brand.spec.ts new file mode 100644 index 0000000..038a536 --- /dev/null +++ b/tests/e2e/partner-brand.spec.ts @@ -0,0 +1,117 @@ +import { expect, test } from "./helpers/fixtures"; +import { RUN_ID, login, uniq } from "./helpers/flows"; +import { + arrangeBillingIdentityFor, + arrangePartnerBrand, + arrangePartnerUser, + findPartnerBrand, +} from "./helpers/db"; + +// A partner recording the PUBLIC details they may one day be shown by +// (SOFRA-PARTNER-PLAN §11). +// +// What this suite proves: the details are stored, the prefill carries the trade +// name and NOT the billing address, the publish switch is present but inert, and +// one partner's save cannot reach another partner's row even when the payload +// says it should. +// +// What it deliberately does NOT try to prove: that anything is published. Nothing +// consumes `publishToTenants` yet — that is §11e, an open owner decision — so +// there is no rendered footer to assert against, and a test that pretended +// otherwise would be asserting a feature that does not exist. + +// Derived from the run id rather than written as a literal, for the reason +// partner-trial.spec.ts documents: a `const PASSWORD = "…"` line is a gitleaks +// `generic-api-key` hit (entropy, not meaning), and a scan that cries wolf on a +// test fixture is a scan people learn to skip past. +const PASSWORD = `e2e-brand-${RUN_ID}`; + +test("a partner saves their public details, and the publish switch is inert", async ({ page }) => { + const email = uniq.email("brandsave"); + const partnerId = await arrangePartnerUser(email, PASSWORD); + + await login(page, { email, password: PASSWORD }); + await page.waitForURL("**/dashboard"); + await page.getByRole("link", { name: /brand/i }).first().click(); + await page.waitForURL("**/dashboard/brand"); + + await page.getByLabel(/display name/i).fill("Solution Eva"); + await page.getByLabel(/^city$/i).fill("Genève"); + await page.getByLabel(/website/i).fill("https://solutioneva.com"); + + // Present, so the partner can see what is being decided about them — and + // disabled, because nothing consumes it. An enabled switch that changed nothing + // would be a lie told to the one person entitled to decide this. + const publish = page.getByRole("checkbox"); + await expect(publish).toBeDisabled(); + await expect(page.getByText(/not available yet/i)).toBeVisible(); + + await page.getByRole("button", { name: /^save$/i }).click(); + await expect(page.getByText(/^saved\.$/i)).toBeVisible(); + + expect(await findPartnerBrand(partnerId)).toMatchObject({ + displayName: "Solution Eva", + city: "Genève", + // A disabled checkbox is not submitted, and the action reads an absent one as + // false. Nothing a partner can do on this page turns publishing on today. + publish: false, + }); +}); + +test("the prefill carries the trade name and NOT the billing address", async ({ page }) => { + const email = uniq.email("brandprefill"); + const partnerId = await arrangePartnerUser(email, PASSWORD); + await arrangeBillingIdentityFor(partnerId, { + legalName: "Eva Obresse", + tradeName: "Solution Eva", + // A home address, which is what a sole trader's billing record actually is. + // It must not appear on a page whose whole subject is what may become public. + addressLine1: "Chemin Privé 7", + }); + + await login(page, { email, password: PASSWORD }); + await page.waitForURL("**/dashboard"); + await page.goto("/dashboard/brand"); + + await expect(page.getByLabel(/display name/i)).toHaveValue("Solution Eva"); + await expect(page.getByLabel(/^address$/i)).toHaveValue(""); + await expect(page.getByLabel(/^postcode$/i)).toHaveValue(""); + await expect(page.getByLabel(/^city$/i)).toHaveValue(""); + // Not merely absent from the fields — absent from the page. + await expect(page.getByText("Chemin Privé 7")).toHaveCount(0); + // And nothing was stored by the mere act of looking: a prefill is a default in + // an editable box, not a write. + expect(await findPartnerBrand(partnerId)).toBeNull(); +}); + +test("a partner cannot write another partner's brand, even by naming them", async ({ page }) => { + const mine = uniq.email("brandmine"); + const theirs = uniq.email("brandtheirs"); + const mineId = await arrangePartnerUser(mine, PASSWORD); + const theirsId = await arrangePartnerUser(theirs, PASSWORD); + await arrangePartnerBrand(theirsId, "Their Brand"); + + await login(page, { email: mine, password: PASSWORD }); + await page.waitForURL("**/dashboard"); + await page.goto("/dashboard/brand"); + + // Their id is INJECTED into the payload — the shape of the attack the action is + // written against. The key comes from the session, so the field is ignored; if + // it were ever read, this is the request that would take over another company's + // public identity. + await page.evaluate((victimId) => { + const form = document.querySelector("form"); + const field = document.createElement("input"); + field.type = "hidden"; + field.name = "partnerId"; + field.value = victimId; + form?.appendChild(field); + }, theirsId); + + await page.getByLabel(/display name/i).fill("My Brand"); + await page.getByRole("button", { name: /^save$/i }).click(); + await expect(page.getByText(/^saved\.$/i)).toBeVisible(); + + expect(await findPartnerBrand(mineId)).toMatchObject({ displayName: "My Brand" }); + expect(await findPartnerBrand(theirsId)).toMatchObject({ displayName: "Their Brand" }); +}); diff --git a/tests/unit/partner-brand.test.ts b/tests/unit/partner-brand.test.ts new file mode 100644 index 0000000..ca54390 --- /dev/null +++ b/tests/unit/partner-brand.test.ts @@ -0,0 +1,207 @@ +import { describe, expect, it } from "vitest"; +import { + checkboxOn, + partnerBrandSchema, + prefillFromBillingIdentity, + renderableBrand, + type StoredBrand, +} from "@/lib/partner-brand"; + +// The pure half of a partner's PUBLIC brand (SOFRA-PARTNER-PLAN §11). +// +// Two things are being defended here and they are different. The schema decides +// what a partner is ALLOWED TO STORE — the interesting cases are the refusals, +// because an accepted `javascript:` URL becomes an `href` on a stranger's +// restaurant page. `renderableBrand` decides what may be SHOWN, and its whole +// value is a negative: a complete, well-filled record that was never opted in +// must come back as `null`. + +const parse = (over: Record = {}) => + partnerBrandSchema.safeParse({ displayName: "Solution Eva", publishToTenants: false, ...over }); + +const full: StoredBrand = { + displayName: "Solution Eva", + tagline: "Restaurant software, set up for you", + websiteUrl: "https://solutioneva.com", + email: "hello@solutioneva.com", + phone: "+41 22 000 00 00", + addressLine1: "Rue du Rhône 1", + postalCode: "1204", + city: "Genève", + countryCode: "CH", + publishToTenants: true, +}; + +describe("partnerBrandSchema — displayName", () => { + it("accepts a plain brand name", () => { + const r = parse(); + expect(r.success && r.data.displayName).toBe("Solution Eva"); + }); + it("trims", () => { + const r = parse({ displayName: " Solution Eva " }); + expect(r.success && r.data.displayName).toBe("Solution Eva"); + }); + it("refuses an empty name — the record means nothing without one", () => { + expect(parse({ displayName: "" }).success).toBe(false); + }); + it("refuses whitespace posing as a name", () => { + expect(parse({ displayName: " " }).success).toBe(false); + }); + it("refuses over 80 characters", () => { + expect(parse({ displayName: "a".repeat(81) }).success).toBe(false); + }); +}); + +describe("partnerBrandSchema — websiteUrl is https or nothing", () => { + it("accepts an https address", () => { + const r = parse({ websiteUrl: "https://solutioneva.com" }); + expect(r.success && r.data.websiteUrl).toBe("https://solutioneva.com"); + }); + it("accepts one with a path", () => { + expect(parse({ websiteUrl: "https://solutioneva.com/fr/contact" }).success).toBe(true); + }); + // The three refusals that matter, and why each is not a style preference: + it("refuses http — we would be advertising a downgrade on someone else's site", () => { + expect(parse({ websiteUrl: "http://solutioneva.com" }).success).toBe(false); + }); + it("refuses javascript: — that is script execution in a reader's page", () => { + expect(parse({ websiteUrl: "javascript:alert(1)" }).success).toBe(false); + }); + it("refuses a bare host rather than guessing a scheme for it", () => { + expect(parse({ websiteUrl: "solutioneva.com" }).success).toBe(false); + }); + it("refuses data: too", () => { + expect(parse({ websiteUrl: "data:text/html," }).success).toBe(false); + }); +}); + +describe("partnerBrandSchema — countryCode is a COUNTRY, not two letters", () => { + it("accepts CH", () => { + const r = parse({ countryCode: "CH" }); + expect(r.success && r.data.countryCode).toBe("CH"); + }); + it("uppercases and trims", () => { + const r = parse({ countryCode: " ch " }); + expect(r.success && r.data.countryCode).toBe("CH"); + }); + // The same negative control `lib/country-code.ts` was written for: `SW` is + // assigned to nothing, and it sat on a live record for nine days looking fine. + it("refuses SW, which is not assigned to anything", () => { + expect(parse({ countryCode: "SW" }).success).toBe(false); + }); + it("refuses UK, the common mistake for GB", () => { + expect(parse({ countryCode: "UK" }).success).toBe(false); + }); + it("refuses a three-letter code", () => { + expect(parse({ countryCode: "CHE" }).success).toBe(false); + }); +}); + +describe("partnerBrandSchema — an untouched field is ABSENT, not empty", () => { + it("turns every blank optional into undefined", () => { + const r = parse({ + tagline: "", + websiteUrl: " ", + email: "", + phone: "", + addressLine1: "", + postalCode: "", + city: "", + countryCode: " ", + }); + expect(r.success).toBe(true); + if (!r.success) return; + for (const key of [ + "tagline", + "websiteUrl", + "email", + "phone", + "addressLine1", + "postalCode", + "city", + "countryCode", + ] as const) { + expect(r.data[key], `${key} should be undefined, not ""`).toBeUndefined(); + } + }); + it("still refuses a malformed email when one IS given", () => { + expect(parse({ email: "not-an-address" }).success).toBe(false); + }); + it("caps an over-long optional field", () => { + expect(parse({ tagline: "a".repeat(121) }).success).toBe(false); + }); + it("requires publishToTenants to be a boolean, never a form string", () => { + expect(parse({ publishToTenants: "on" }).success).toBe(false); + }); +}); + +describe("checkboxOn", () => { + it("reads a ticked box", () => expect(checkboxOn("on")).toBe(true)); + it("reads the explicit string", () => expect(checkboxOn("true")).toBe(true)); + it("an absent box is off", () => expect(checkboxOn(null)).toBe(false)); + it("anything else is off — the safe direction for a publish flag", () => { + expect(checkboxOn("yes")).toBe(false); + expect(checkboxOn("1")).toBe(false); + expect(checkboxOn(true)).toBe(false); + }); +}); + +describe("prefillFromBillingIdentity — one field crosses, and only one", () => { + it("takes the trade name", () => { + expect(prefillFromBillingIdentity({ legalName: "Eva Obresse", tradeName: "Solution Eva" })).toEqual( + { displayName: "Solution Eva" }, + ); + }); + it("falls back to the legal name when there is no trade name", () => { + expect(prefillFromBillingIdentity({ legalName: "Eva Obresse", tradeName: null })).toEqual({ + displayName: "Eva Obresse", + }); + }); + it("ignores a blank trade name rather than prefilling an empty box", () => { + expect(prefillFromBillingIdentity({ legalName: "Eva Obresse", tradeName: " " })).toEqual({ + displayName: "Eva Obresse", + }); + }); + it("returns null when there is no identity at all", () => { + expect(prefillFromBillingIdentity(null)).toBeNull(); + expect(prefillFromBillingIdentity(undefined)).toBeNull(); + expect(prefillFromBillingIdentity({ legalName: " ", tradeName: null })).toBeNull(); + }); + // The load-bearing assertion of the whole two-model split: a prefill carries a + // NAME and cannot carry an address, because there is nowhere in its result for + // one to go. + it("carries exactly one key, so no address can ride along", () => { + const out = prefillFromBillingIdentity({ legalName: "Eva Obresse", tradeName: "Solution Eva" }); + expect(Object.keys(out ?? {})).toEqual(["displayName"]); + }); +}); + +describe("renderableBrand — the single door", () => { + it("passes a published brand through, field for field", () => { + expect(renderableBrand(full)).toEqual({ + displayName: full.displayName, + tagline: full.tagline, + websiteUrl: full.websiteUrl, + email: full.email, + phone: full.phone, + addressLine1: full.addressLine1, + postalCode: full.postalCode, + city: full.city, + countryCode: full.countryCode, + }); + }); + it("never leaks the flag itself into the projection", () => { + expect(renderableBrand(full)).not.toHaveProperty("publishToTenants"); + }); + // THE negative control. A record with every field filled in — the one a naive + // caller would happily render — comes back as null purely because nobody opted + // in. If this ever returns an object, a partner who never consented is in a + // stranger's footer and nothing else in the system goes red. + it("returns null for an UNPUBLISHED brand carrying full data", () => { + expect(renderableBrand({ ...full, publishToTenants: false })).toBeNull(); + }); + it("returns null when there is no brand", () => { + expect(renderableBrand(null)).toBeNull(); + expect(renderableBrand(undefined)).toBeNull(); + }); +}); diff --git a/vitest.config.ts b/vitest.config.ts index 7862516..1d1eaac 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -109,6 +109,12 @@ export default defineConfig({ // and whether a resolver's answer actually proves control of it. The transport // (`base-domain-dns.ts`) stays out, same split as vies/vies-result. "lib/base-domain.ts", + // §11 — a partner's PUBLIC brand. In scope because its whole job is a + // negative: `renderableBrand` is the single door an unpublished record + // must not get through, and a leak there is silent by construction — it + // looks like a footer with a name in it. The https-only and ISO-country + // refusals are decidable here too, and nowhere cheaper. + "lib/partner-brand.ts", "lib/base-domain-verification.ts", // D2 — which of the four domain shapes a partner proposed, and what DNS it // needs. Pure by construction: it cannot see whose base domain it was handed or From 48091a55495c58308907bea34680bdff6d5f945e Mon Sep 17 00:00:00 2001 From: mahmutKaya <33642821+mahmutkaya@users.noreply.github.com> Date: Sun, 30 Aug 2026 17:53:42 +0200 Subject: [PATCH 2/3] feat(partner): publish a partner's brand into the registry entry, and refuse their legal name (S3a) (#205) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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 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. --- app/(control)/dashboard/brand/page.tsx | 28 +++-- components/control/PartnerBrandForm.tsx | 35 ++++++- lib/partner-brand-lookup.ts | 54 ++++++++++ lib/partner-brand-publish.ts | 117 +++++++++++++++++++++ lib/partner-brand.ts | 84 +++------------ lib/provisioning-pr-blocks.ts | 127 +++++++++++++++++++++++ lib/provisioning-pr-body.ts | 74 +++---------- lib/provisioning-registry.ts | 24 +++++ lib/provisioning.ts | 17 ++- messages/ar.json | 2 + messages/de.json | 2 + messages/en.json | 2 + messages/fr.json | 2 + messages/nl.json | 2 + messages/tr.json | 2 + tests/e2e/partner-brand.spec.ts | 35 +++++++ tests/unit/partner-brand.test.ts | 100 ++++++++++++++++-- tests/unit/provisioning-registry.test.ts | 91 ++++++++++++++++ vitest.config.ts | 9 ++ 19 files changed, 655 insertions(+), 152 deletions(-) create mode 100644 lib/partner-brand-lookup.ts create mode 100644 lib/partner-brand-publish.ts create mode 100644 lib/provisioning-pr-blocks.ts diff --git a/app/(control)/dashboard/brand/page.tsx b/app/(control)/dashboard/brand/page.tsx index 6835036..40f5b82 100644 --- a/app/(control)/dashboard/brand/page.tsx +++ b/app/(control)/dashboard/brand/page.tsx @@ -33,14 +33,20 @@ export default async function PartnerBrandPage() { // answering a question nobody asked. Nothing from this record is stored by // loading the page; the partner sees the value in an editable field and saves it // themselves (lib/partner-brand.ts explains why only this one field crosses). - const prefill = brand - ? null - : prefillFromBillingIdentity( - await db.billingIdentity.findUnique({ - where: { userId: partner.id }, - select: { legalName: true, tradeName: true }, - }), - ); + // Read whether or not there is a brand already, because it now answers TWO + // questions and only the first is about prefilling. The second is D-B1a: a + // display name that IS the legal name will not be published, so the form has to + // know the legal name to be able to say so — and a partner whose record has no + // TRADE name is the one who needs telling before they type, since the only other + // name we hold is their own. Nothing from this record is stored by loading the + // page, and none of it is rendered: `legalName` reaches the client as a + // comparison input, which is a value the partner typed into their own billing + // record and is being shown their own copy of. + const identity = await db.billingIdentity.findUnique({ + where: { userId: partner.id }, + select: { legalName: true, tradeName: true }, + }); + const prefill = brand ? null : prefillFromBillingIdentity(identity); return (
@@ -54,7 +60,11 @@ export default async function PartnerBrandPage() { {!brand && prefill && (

{t("prefillNote")}

)} - +
); diff --git a/components/control/PartnerBrandForm.tsx b/components/control/PartnerBrandForm.tsx index ed134bc..ef64625 100644 --- a/components/control/PartnerBrandForm.tsx +++ b/components/control/PartnerBrandForm.tsx @@ -1,11 +1,12 @@ "use client"; -import { useActionState } from "react"; +import { useActionState, useState } from "react"; import { useTranslations } from "next-intl"; import { savePartnerBrandAction, type PartnerBrandState, } from "@/lib/actions/partner-brand-actions"; +import { isLegalNameEcho } from "@/lib/partner-brand"; import ActionError from "./ActionError"; /** What the page hands in — either the stored row or, for a partner who has none @@ -38,13 +39,24 @@ type TextField = Exclude; */ export default function PartnerBrandForm({ defaults, -}: Readonly<{ defaults?: BrandDefaults }>) { + legalName, + hasTradeName, +}: Readonly<{ defaults?: BrandDefaults; legalName?: string | null; hasTradeName?: boolean }>) { const t = useTranslations("control.brand"); const [state, action, pending] = useActionState( savePartnerBrandAction, {}, ); + // Watched, so the partner is told BEFORE they save rather than after — and the + // same predicate the publish choke point uses (lib/partner-brand.ts), so the UI + // cannot promise something `renderableBrand` would then refuse. Saving is still + // allowed: it is their record, and refusing the write would leave them with no way + // to record their own name. What is refused is PUBLISHING it, and the note says so + // rather than the form accepting it silently and dropping it later (D-B1a). + const [displayName, setDisplayName] = useState(defaults?.displayName ?? ""); + const echoesLegalName = isLegalNameEcho(displayName, legalName); + const field = ( name: TextField, opts: { required?: boolean; type?: string; maxLength?: number } = {}, @@ -57,6 +69,7 @@ export default function PartnerBrandForm({ required={opts.required} maxLength={opts.maxLength ?? 200} defaultValue={defaults?.[name] ?? ""} + onChange={name === "displayName" ? (e) => setDisplayName(e.target.value) : undefined} className="input-primary" /> @@ -64,7 +77,25 @@ export default function PartnerBrandForm({ return (
+ {/* Said once, up front, to the partner it applies to: a sole trader whose + billing record has no trade name has nothing we could publish, because the + only other name we hold is their own (§11b). Better here, where they are + about to type, than as a refusal after they save. */} + {legalName && !hasTradeName && ( +

+ {t("noTradeNameNote")} +

+ )} {field("displayName", { required: true, maxLength: 80 })} + {/* `` rather than `role="status"`: same live region, and the native + element is announced by assistive tech that does not implement the ARIA + role (S6819). It is a live region on purpose — the partner types and the + verdict changes under them, so it has to be spoken, not merely rendered. */} + {echoesLegalName && ( + + {t("legalNameNotPublished")} + + )} {field("tagline", { maxLength: 120 })} {field("websiteUrl", { type: "url", maxLength: 200 })} {field("email", { type: "email" })} diff --git a/lib/partner-brand-lookup.ts b/lib/partner-brand-lookup.ts new file mode 100644 index 0000000..1a4fa93 --- /dev/null +++ b/lib/partner-brand-lookup.ts @@ -0,0 +1,54 @@ +// Which partner, if any, may be credited in a tenant's footer (SOFRA-PARTNER-PLAN +// §11e). The DB half of the feature, deliberately separate from the pure rules in +// lib/partner-brand.ts: this file only GATHERS the two records, and the decision +// stays behind `renderableBrand`, which is the one door. +// +// Out of the vitest coverage floor for the reason `vies.ts` is — it queries — while +// everything it can get wrong about publishing is decided in a module that is in. + +import { db } from "@/lib/db"; +import { renderableBrand, type RenderableBrand } from "@/lib/partner-brand"; + +/** + * The publishable brand of the partner who sold this tenant, or `undefined`. + * + * `undefined`, not `null`, because that is what `TenantProvisionInput.partnerBrand` + * wants: absence is the contract there, and an entry with no credit must be + * byte-identical to one generated before the field existed. + * + * The slug is matched two ways on purpose. `Client.tenantSlug` is set by an ADMIN + * *after* provisioning, so at the moment a registry PR is proposed it is usually + * still null; the reseller's PLAN, however, already names the slug. Looking only at + * `Client.tenantSlug` would therefore have credited nobody on exactly the path this + * feature exists for, and the failure would have been silent — an entry with no + * partner keys reads the same as a partner who never opted in. + * + * Fails OPEN, to no credit: a database hiccup here must not stop a paid customer's + * tenant being proposed, and the cost of the safe direction is a missing footer line + * that a re-provision restores. The opposite direction is unrecoverable in kind — + * publishing a name because a lookup misfired. + */ +export async function tenantPartnerBrand(tenantSlug: string): Promise { + try { + const client = await db.client.findFirst({ + where: { OR: [{ tenantSlug }, { billing: { tenantSlug } }] }, + select: { partnerId: true }, + }); + if (!client) return undefined; + + // The legal name is fetched for ONE purpose — the D-B1a refusal — and never + // travels further: `renderableBrand` compares with it and does not return it. + const [brand, identity] = await Promise.all([ + db.partnerBrand.findUnique({ where: { partnerId: client.partnerId } }), + db.billingIdentity.findUnique({ + where: { userId: client.partnerId }, + select: { legalName: true }, + }), + ]); + return renderableBrand(brand, { legalName: identity?.legalName }) ?? undefined; + } catch (e) { + // Slug only: a brand is a company's, and for a sole trader a person's (§5.8). + console.error("tenantPartnerBrand failed; proposing without a credit", tenantSlug, e); + return undefined; + } +} diff --git a/lib/partner-brand-publish.ts b/lib/partner-brand-publish.ts new file mode 100644 index 0000000..9195908 --- /dev/null +++ b/lib/partner-brand-publish.ts @@ -0,0 +1,117 @@ +// A partner's PUBLIC brand — the PUBLISH half (SOFRA-PARTNER-PLAN §11e). +// +// Split out of lib/partner-brand.ts when the pair outgrew one file's LOC limit +// (CLAUDE.md §4), along the line the feature already had: that file says what a +// partner may STORE, this one says what may be SHOWN. Both stay pure — no `db`, +// no I/O, nothing that can read a session — and both stay in the coverage floor, +// listed explicitly in vitest.config.ts so the split moved no code out of scope. +// +// `@/lib/partner-brand` re-exports everything here, so it remains the one door +// callers need to know about. + +/** + * `https://` and nothing else. + * + * This value ends up in an `href` on a page served to the public, so the scheme + * is the security boundary, not a formatting preference. `javascript:` is script + * execution in the reader's page; `http:` is a downgrade we would be advertising + * on someone else's site. A bare host (`example.com`) is refused rather than + * silently prefixed: guessing a scheme for a partner is exactly how `http` gets + * published by accident, and the form asks for the full address. + * + * `URL` does the parsing, so no regex has to be right about hosts. + */ +export function isHttpsUrl(value: string): boolean { + try { + return new URL(value).protocol === "https:"; + } catch { + return false; + } +} + +/** What is stored. Structural, for the same reason as `IdentityNames`. */ +export type StoredBrand = { + displayName: string; + tagline: string | null; + websiteUrl: string | null; + email: string | null; + phone: string | null; + addressLine1: string | null; + postalCode: string | null; + city: string | null; + countryCode: string | null; + publishToTenants: boolean; +}; + +/** + * Exactly what a public surface may render — an ATTRIBUTION, not a contact block + * (SOFRA-PARTNER-PLAN D-B1): a name and, when there is one, a link — *"Site by + * "*. The address, phone, email and tagline this used to carry are + * deliberately GONE. Dropping fields is the self-announcing direction (the + * assertions naming them go red); widening back to a contact block is the silent + * one and needs its own controls plus a `pii-inventory.md` row (D-B4) — a sole + * trader's business email and phone are still personal data. + * + * `websiteUrl` is ABSENT rather than null, so this is already the shape the + * registry generator consumes: a key it does not emit. + */ +export type RenderableBrand = { displayName: string; websiteUrl?: string }; + +/** trim · case-fold · collapse internal whitespace. Comparison only — nothing + * normalised here is ever stored or shown. */ +const normalizeName = (value: string): string => + value.trim().toLowerCase().replaceAll(/\s+/g, " "); + +/** + * Is this "brand" just the legal name typed again? (D-B1a) + * + * NORMALISED, because the trap is a PREFILLED value saved with a stray space or a + * different case: " mustafa VURAL " is the same person as "Mustafa Vural", and + * an exact-string check would publish them onto a restaurant's public page. + * + * Exported so the FORM can say so, in the partner's own language, before they + * save. One predicate, two readers — a second copy in the UI is the one that + * drifts, and it drifts towards permissive. + */ +export function isLegalNameEcho( + displayName: string | null | undefined, + legalName: string | null | undefined, +): boolean { + const brand = normalizeName(displayName ?? ""); + return brand.length > 0 && brand === normalizeName(legalName ?? ""); +} + +/** + * The ONLY way a brand reaches a public surface — and the only place it is + * refused. Returns null when: there is no brand · `publishToTenants` is false · + * `displayName` is blank · `displayName` IS the legal name. **A missing brand + * renders NOTHING; it never falls back.** + * + * A single choke point on purpose: the alternative — every future caller + * remembering the flag and the legal-name comparison — holds until the first + * caller that forgets, and that failure is silent, a name nobody consented to + * publish sitting in a stranger's footer with nothing going red. + * + * It takes the LEGAL name because D-B1a is a rule about the RELATIONSHIP between + * the two records, so the one function allowed to publish must know both. A named + * option, not a positional string: a caller that omits it disarms the echo rule + * visibly, instead of sliding a name into the wrong slot. And it RE-PROJECTS + * rather than spreading the row, so a column added to `PartnerBrand` later is not + * published by the mere fact of having been added. + */ +export function renderableBrand( + brand: StoredBrand | null | undefined, + opts: { legalName?: string | null } = {}, +): RenderableBrand | null { + if (!brand?.publishToTenants) return null; + const displayName = brand.displayName.trim(); + if (!displayName) return null; + if (isLegalNameEcho(displayName, opts.legalName)) return null; + // Re-checked though the write schema already refuses anything but https: + // defence in depth on the one field that becomes an `href` on a public page. A + // row predating the schema, or written by a future admin tool, must not publish + // a `javascript:` link — and dropping the LINK is the right refusal, since the + // name is still what the partner asked to show. + const websiteUrl = brand.websiteUrl?.trim(); + return { displayName, ...(websiteUrl && isHttpsUrl(websiteUrl) ? { websiteUrl } : {}) }; +} diff --git a/lib/partner-brand.ts b/lib/partner-brand.ts index 77af13f..02c0297 100644 --- a/lib/partner-brand.ts +++ b/lib/partner-brand.ts @@ -1,4 +1,8 @@ -// A partner's PUBLIC brand — the pure rules about it (SOFRA-PARTNER-PLAN §11). +// A partner's PUBLIC brand — the WRITE rules about it (SOFRA-PARTNER-PLAN §11). +// +// What may be SHOWN moved to lib/partner-brand-publish.ts when the pair outgrew +// one file's LOC limit (CLAUDE.md §4) and is re-exported at the foot of this +// file, so `@/lib/partner-brand` is still the only import path anyone needs. // // Pure by construction: no `db` import, no I/O, nothing that can read a session. // Everything here is either "is this input acceptable" or "may this record be @@ -14,6 +18,7 @@ import { z } from "zod"; import { isAssignedCountryCode } from "@/lib/country-code"; +import { isHttpsUrl } from "@/lib/partner-brand-publish"; /** * An empty form field is ABSENT, not the empty string. @@ -29,26 +34,6 @@ const blankToUndefined = (value: unknown): unknown => const optionalText = (max: number) => z.preprocess(blankToUndefined, z.string().trim().max(max).optional()); -/** - * `https://` and nothing else. - * - * This value ends up in an `href` on a page served to the public, so the scheme - * is the security boundary, not a formatting preference. `javascript:` is script - * execution in the reader's page; `http:` is a downgrade we would be advertising - * on someone else's site. A bare host (`example.com`) is refused rather than - * silently prefixed: guessing a scheme for a partner is exactly how `http` gets - * published by accident, and the form asks for the full address. - * - * `URL` does the parsing, so no regex has to be right about hosts. - */ -function isHttpsUrl(value: string): boolean { - try { - return new URL(value).protocol === "https:"; - } catch { - return false; - } -} - /** * What a partner must supply to record a public brand. * @@ -129,50 +114,13 @@ export function prefillFromBillingIdentity( return name ? { displayName: name } : null; } -/** What is stored. Structural, for the same reason as `IdentityNames`. */ -export type StoredBrand = { - displayName: string; - tagline: string | null; - websiteUrl: string | null; - email: string | null; - phone: string | null; - addressLine1: string | null; - postalCode: string | null; - city: string | null; - countryCode: string | null; - publishToTenants: boolean; -}; - -/** Exactly the fields a public surface may render. */ -export type RenderableBrand = Omit; - -/** - * The ONLY way an unpublished brand can reach a public surface: it cannot. - * - * A single choke point on purpose. The alternative — every future caller - * remembering to test `publishToTenants` before it renders — is a rule that holds - * until the first caller that forgets, and the failure is silent: a partner who - * never opted in appears in a footer and nothing goes red. Here, forgetting means - * calling this function and getting `null`. - * - * It also RE-PROJECTS rather than spreading the row, so a column added to - * PartnerBrand later (a founder note, an internal flag) is not published by the - * mere fact of having been added. - * - * Nothing consumes this yet — the publishing half is gated on an owner decision. - * It ships now so that when something does, the gate is already the only door. - */ -export function renderableBrand(brand: StoredBrand | null | undefined): RenderableBrand | null { - if (!brand?.publishToTenants) return null; - return { - displayName: brand.displayName, - tagline: brand.tagline, - websiteUrl: brand.websiteUrl, - email: brand.email, - phone: brand.phone, - addressLine1: brand.addressLine1, - postalCode: brand.postalCode, - city: brand.city, - countryCode: brand.countryCode, - }; -} +// The PUBLISH half, re-exported so `@/lib/partner-brand` stays the one import +// path for this feature. It lives in its own file only because the pair outgrew +// the 200-LOC limit (CLAUDE.md §4); `renderableBrand` is still the single door. +export { + isHttpsUrl, + isLegalNameEcho, + renderableBrand, + type RenderableBrand, + type StoredBrand, +} from "@/lib/partner-brand-publish"; diff --git a/lib/provisioning-pr-blocks.ts b/lib/provisioning-pr-blocks.ts new file mode 100644 index 0000000..ddf5440 --- /dev/null +++ b/lib/provisioning-pr-blocks.ts @@ -0,0 +1,127 @@ +// The CONDITIONAL sections of an ADR-012 provisioning PR body — the parts that +// appear only when the entry carries something the founder has to look at before +// merging (a withheld module, a partner's own zone, a partner credit). +// +// Split out of lib/provisioning-pr-body.ts when the pair outgrew one file's LOC +// limit (CLAUDE.md §4), the same split that file records having had from +// provisioning-registry.ts. Each function returns markdown LINES and an empty +// array when its condition does not hold, so the body composes them by spreading +// — a section that does not apply contributes nothing, not a blank heading. +// +// Pure: no GitHub API, no secrets, no env. + +import type { TenantProvisionInput } from "./provisioning-registry"; + +/** + * Bought but deliberately NOT in this entry. + * + * Named, not silent. The entry omits a module they PAID for, so the body has to say + * so where the founder is already reading — otherwise the checklist quietly + * contradicts the receipt, and the gap is discovered by a customer asking why card + * payment does not work. Empty for every tenant that bought nothing deferred. + */ +export function deferredSection( + slug: string, + granted: string[], + deferred: string[], +): string[] { + if (!deferred.length) return []; + return [ + "", + `### ⚠️ Bought but deliberately NOT in this entry: \`${deferred.join(", ")}\``, + "", + `They paid for \`${deferred.join(", ")}\` and they keep it — the plan, the price and the`, + "subscription are unchanged. It is out of **this** entry because `provision-tenant.sh`", + "refuses the pair `online-payments` without `stripe_account:`, and refuses it *before*", + "the database, the compose project or the image — so proposing both here would not give", + "them a restaurant lacking card payment, it would give them **no restaurant at all**.", + "", + "No account was supplied with this proposal. If you are the founder and you already", + "hold their `acct_` — runbook §2b has you create it *before* proposing, exactly so", + "this does not happen — the fix is one shot, not two: add both fields in **Files", + "changed** before merging and delete this section's premise. Otherwise the account", + "genuinely cannot exist yet, because only the restaurant can create it, through", + "Stripe's hosted onboarding, which cannot be pre-filled. In that case provision them", + "now on everything else, then, once they have finished Stripe and you have their id, open a", + "**second** registry PR that adds BOTH halves together — one without the other trips", + "the same guard. Only these two fields change; the rest of the entry stays as merged:", + "", + "```yaml", + ` ${slug}:`, + " stripe_account: acct_XXXXXXXXXXXX", + ` modules: [${[...granted, ...deferred].join(", ")}]`, + "```", + "", + `…then re-run provisioning (\`gh workflow run provision-tenant.yml --repo piwas-21/restaurant-app-deploy -f slug=${slug}\`)`, + "and restart the tenant so it picks up the Stripe env. Full recipe — account creation,", + "the KYC sitting, TWINT, the box env — workspace `docs/runbooks/signup-to-live-tenant.md`", + "**§2b**, which is written to be followed BEFORE this second PR.", + ]; +} + +/** + * A partner's own zone. + * + * The one thing that can go wrong with a partner-zone entry and cannot be fixed after + * the merge: the name has to RESOLVE before provisioning, because the certificate is + * issued per hostname over HTTP-01 and there is no way to pre-issue one. A tenant + * provisioned ahead of its A record sits without TLS, which looks exactly like a + * broken product to the restaurant that was just handed a link. + */ +export function baseDomainSection(baseDomain: string | undefined, domain: string): string[] { + if (!baseDomain) return []; + return [ + "", + `### ⚠️ Partner zone — \`base_domain: ${baseDomain}\`, so the wildcard does NOT cover it`, + "", + `\`\`\`bash\ndig +short ${domain} # must already answer with this box's IP\n\`\`\``, + "", + "The partner publishes that A record in their own zone (plan §D1a: per-client A record —", + "a delegated subzone is impossible, not merely worse). An empty answer means merging will", + "stand the tenant up and then fail to get a certificate, which are issued per hostname over", + "HTTP-01 and cannot be pre-issued: **wait for the record rather than merge and retry.** And", + "note `NEXT_PUBLIC_*` are baked per domain, so changing this domain later is a rebuild plus", + "a re-provision, not a registry edit.", + ]; +} + +/** + * A partner credit is about to become PUBLIC (SOFRA-PARTNER-PLAN §11e). + * + * This section exists because the registry PR is the founder's review checkpoint + * (ADR-012), and this is the moment someone should notice that a name and a link are + * about to appear on a restaurant's page — a page belonging to a third party. It is + * the last cheap place to say "no": after the merge the entry is provisioned, and + * un-publishing means another registry PR and another re-provision. + * + * It also names the field the RESTAURANT can be given (D-B2), because the founder is + * the only party who can add it on their behalf, and the entry deliberately does not + * carry it by default: absent means attribution is on. + */ +export function partnerSection( + slug: string, + brand: NonNullable, +): string[] { + return [ + "", + `### 👤 This entry credits a partner in the tenant's footer: \`${brand.displayName}\``, + "", + `The footer of every page on this restaurant's site will read *"Site by ${brand.displayName}"*${ + brand.websiteUrl ? `, linked to \`${brand.websiteUrl}\`` : " (no link — the partner recorded no website)" + }.`, + "It is here because that partner asked for it on `/dashboard/brand`, and only a name and", + "a URL cross: no address, no phone, and never the legal name — a sole trader's legal name", + "is a private individual's, which is why the control plane refuses to publish it at all.", + "", + "**Check the restaurant is content to be credited.** If they are not, add one line to this", + "entry before merging — the default is on, so the key only ever appears to turn it off:", + "", + "```yaml", + ` ${slug}:`, + " partner_attribution: false", + "```", + "", + "It is resolved during provisioning, so the tenant's env carries only what to display.", + "Changing it later is a registry edit plus a re-provision (a `restart` re-reads nothing).", + ]; +} diff --git a/lib/provisioning-pr-body.ts b/lib/provisioning-pr-body.ts index 6a0ac3f..bf832e1 100644 --- a/lib/provisioning-pr-body.ts +++ b/lib/provisioning-pr-body.ts @@ -8,6 +8,9 @@ // checklist against it. import { splitDeferredModules, tenantDomain, type TenantProvisionInput } from "./provisioning-registry"; +// The conditional sections live next door (LOC limit, CLAUDE.md §4). Each returns an +// empty array when it does not apply, so this file spreads them unconditionally. +import { baseDomainSection, deferredSection, partnerSection } from "./provisioning-pr-blocks"; // Close the quote, emit an escaped apostrophe, reopen: the only way to get a // literal ' inside a POSIX single-quoted argument. @@ -77,65 +80,6 @@ export function buildProvisioningPrBody(input: TenantProvisionInput): string { "two commands below are **required**, not a fallback. Still check the entry first:", ]; - // Named, not silent. The entry omits a module they PAID for, so the body has to say - // so where the founder is already reading — otherwise the checklist above quietly - // contradicts the receipt, and the gap is discovered by a customer asking why card - // payment does not work. Empty for every tenant that bought nothing deferred. - const deferredBlock = deferred.length - ? [ - "", - `### ⚠️ Bought but deliberately NOT in this entry: \`${deferred.join(", ")}\``, - "", - `They paid for \`${deferred.join(", ")}\` and they keep it — the plan, the price and the`, - "subscription are unchanged. It is out of **this** entry because `provision-tenant.sh`", - "refuses the pair `online-payments` without `stripe_account:`, and refuses it *before*", - "the database, the compose project or the image — so proposing both here would not give", - "them a restaurant lacking card payment, it would give them **no restaurant at all**.", - "", - "No account was supplied with this proposal. If you are the founder and you already", - "hold their `acct_` — runbook §2b has you create it *before* proposing, exactly so", - "this does not happen — the fix is one shot, not two: add both fields in **Files", - "changed** before merging and delete this section's premise. Otherwise the account", - "genuinely cannot exist yet, because only the restaurant can create it, through", - "Stripe's hosted onboarding, which cannot be pre-filled. In that case provision them", - "now on everything else, then, once they have finished Stripe and you have their id, open a", - "**second** registry PR that adds BOTH halves together — one without the other trips", - "the same guard. Only these two fields change; the rest of the entry stays as merged:", - "", - "```yaml", - ` ${slug}:`, - " stripe_account: acct_XXXXXXXXXXXX", - ` modules: [${[...granted, ...deferred].join(", ")}]`, - "```", - "", - `…then re-run provisioning (\`gh workflow run provision-tenant.yml --repo piwas-21/restaurant-app-deploy -f slug=${slug}\`)`, - "and restart the tenant so it picks up the Stripe env. Full recipe — account creation,", - "the KYC sitting, TWINT, the box env — workspace `docs/runbooks/signup-to-live-tenant.md`", - "**§2b**, which is written to be followed BEFORE this second PR.", - ] - : []; - - // The one thing that can go wrong with a partner-zone entry and cannot be fixed after - // the merge: the name has to RESOLVE before provisioning, because the certificate is - // issued per hostname over HTTP-01 and there is no way to pre-issue one. A tenant - // provisioned ahead of its A record sits without TLS, which looks exactly like a - // broken product to the restaurant that was just handed a link. - const baseDomainBlock = input.baseDomain - ? [ - "", - `### ⚠️ Partner zone — \`base_domain: ${input.baseDomain}\`, so the wildcard does NOT cover it`, - "", - `\`\`\`bash\ndig +short ${domain} # must already answer with this box's IP\n\`\`\``, - "", - "The partner publishes that A record in their own zone (plan §D1a: per-client A record —", - "a delegated subzone is impossible, not merely worse). An empty answer means merging will", - "stand the tenant up and then fail to get a certificate, which are issued per hostname over", - "HTTP-01 and cannot be pre-issued: **wait for the record rather than merge and retry.** And", - "note `NEXT_PUBLIC_*` are baked per domain, so changing this domain later is a rebuild plus", - "a re-provision, not a registry edit.", - ] - : []; - const after = chained ? [ "The chain provisions **first-time only**, and reports back on this PR when it is done —", @@ -159,6 +103,11 @@ export function buildProvisioningPrBody(input: TenantProvisionInput): string { }`, `- **box** \`${box}\` · status starts at \`provisioning\`${ input.baseDomain ? ` · **base_domain** \`${input.baseDomain}\` (a partner's own zone)` : "" + }${ + // In the summary as well as its own section below: this is the line a founder + // skims, and a name becoming public is the one thing in the diff that is about + // a third party rather than about the tenant. + input.partnerBrand ? ` · **partner credit** \`${input.partnerBrand.displayName}\` (public)` : "" }`, "", ...header, @@ -172,8 +121,11 @@ export function buildProvisioningPrBody(input: TenantProvisionInput): string { : `- [ ] **modules** \`${granted.join(", ")}\` match what they actually paid for — they are enforced at runtime now, so a missing id is a feature they bought and will not get`, tagCheck, `- [ ] **template** \`${input.template}\` and **currency** \`${input.currency}\` are right — the template is baked into the image at build time, so changing it later is a rebuild`, - ...deferredBlock, - ...baseDomainBlock, + ...deferredSection(slug, granted, deferred), + ...baseDomainSection(input.baseDomain, domain), + // Only when a publishable partner brand reached the entry — `renderableBrand` + // decided that, not this file. + ...(input.partnerBrand ? partnerSection(slug, input.partnerBrand) : []), "", ...after, "", diff --git a/lib/provisioning-registry.ts b/lib/provisioning-registry.ts index 7787f5d..f19a720 100644 --- a/lib/provisioning-registry.ts +++ b/lib/provisioning-registry.ts @@ -84,6 +84,19 @@ export interface TenantProvisionInput { /** The tenant's Stripe connected account (`acct_…`), when they already have one. * Absent on the self-serve path; present when the founder followed runbook §2b. */ stripeAccount?: string; + /** + * The reseller credit this tenant's footer may carry (§11e) — and it is typed as + * the OUTPUT of `renderableBrand`, not as a partner id or a brand row, on purpose. + * That choke point is where `publishToTenants` and the D-B1a legal-name refusal + * are decided, so a caller cannot reach this field without having passed them: + * the only way to obtain a value of this shape is to have been allowed one. + * + * Optional, and ABSENCE IS THE CONTRACT, exactly as `baseDomain` above: without it + * the generator emits precisely what it emitted before this field existed — no + * `partner_name:`, no `partner_url:` — which is every entry in the registry today. + * The proof is that every pre-existing test of this function passes unchanged. + */ + partnerBrand?: { displayName: string; websiteUrl?: string }; city?: string; /** Which box the tenant belongs on; provision-tenant.sh refuses a mismatch. */ box?: string; @@ -163,6 +176,17 @@ export function buildTenantRegistryEntry(input: TenantProvisionInput): { ...(stripeAccount ? { stripe_account: stripeAccount } : {}), // Only emit city when set — the registry field is optional. ...(input.city ? { city: input.city } : {}), + // The partner credit, emitted only when there is a publishable one. NOT + // `partner_attribution:` — absent means true (D-B2), so writing it on every + // entry would be a no-op line on all of them and a diff on all of them. It is + // the RESTAURANT's switch, hand-added on their behalf when they ask for it, and + // resolved in `provision-tenant.sh` so the tenant env carries one meaning only. + ...(input.partnerBrand + ? { + partner_name: input.partnerBrand.displayName, + ...(input.partnerBrand.websiteUrl ? { partner_url: input.partnerBrand.websiteUrl } : {}), + } + : {}), }, }; const block = stringify(entry) diff --git a/lib/provisioning.ts b/lib/provisioning.ts index 8348f58..16af099 100644 --- a/lib/provisioning.ts +++ b/lib/provisioning.ts @@ -7,6 +7,7 @@ // (invariant 2). import { buildProvisioningPrBody } from "@/lib/provisioning-pr-body"; +import { tenantPartnerBrand } from "@/lib/partner-brand-lookup"; import { buildTenantRegistryEntry, type TenantProvisionInput } from "@/lib/provisioning-registry"; const OWNER = "piwas-21"; @@ -82,11 +83,20 @@ export async function openProvisioningPr( throw new ProvisioningApiError(`registry already has a '${input.slug}' entry`); } + // The reseller credit for the tenant's footer (SOFRA-PARTNER-PLAN §11e), resolved + // HERE rather than accepted from a caller. It is DERIVED from the slug — nobody + // types it, and it is not the founder's to type — so filling it at the one place + // that writes a registry entry means no caller can forget it, no caller can inject + // one, and a third path added later inherits it. Whatever the input carried is + // deliberately overwritten: `renderableBrand` behind this lookup is the only thing + // allowed to decide that a name may be published (D-B1/D-B1a). + const partnerBrand = await tenantPartnerBrand(input.slug); + // `deferred` is returned to the caller rather than only rendered into the PR body: a // deferral means a customer is being BILLED for a module their tenant will not have // until a second registry PR lands, and a prose section in one PR is not a record // anyone can query later. The callers put it in the audit trail. - const { entry, deferred } = buildTenantRegistryEntry(input); + const { entry, deferred } = buildTenantRegistryEntry({ ...input, partnerBrand }); // trimEnd() (no regex) drops any trailing whitespace/newlines, then we re-add // exactly two — avoids the ReDoS-prone `\n*$`, and the blank line keeps the // new tenant from butting against the previous one's trailing comment, which @@ -132,7 +142,10 @@ export async function openProvisioningPr( title: `Provision tenant: ${input.slug}`, head: branch, base: BASE, - body: buildProvisioningPrBody(input), + // The SAME resolved credit the entry carries: a body that describes a partner + // the diff does not name (or omits one it does) is worse than no body — the + // founder ticks the checklist against it. + body: buildProvisioningPrBody({ ...input, partnerBrand }), }), }); return { prUrl: pr.html_url, deferred }; diff --git a/messages/ar.json b/messages/ar.json index b641dbf..6a85831 100644 --- a/messages/ar.json +++ b/messages/ar.json @@ -1239,6 +1239,8 @@ "intro": "الاسم وبيانات الاتصال التي قد يراها ضيوف المطعم بوصفها الشركة التي تقف خلف موقعه. تكتبها هنا حتى لا يُنشر سوى ما تختاره أنت.", "privacyNote": "هذه البيانات منفصلة عمداً عن بيانات الفوترة. يبقى عنوان الفاتورة ورقم السجل خاصين ولا يُعرضان لأحد غيرنا.", "prefillNote": "ملأنا اسمك التجاري للبدء. لا يُنسخ أي شيء آخر من بيانات الفوترة — اكتب ما توافق على أن يراه الضيوف.", + "noTradeNameNote": "تتضمن بيانات الفوترة اسماً قانونياً دون اسم تجاري. لا يمكننا نشر اسم شخص على موقع مطعم، فاكتب من فضلك العلامة التجارية التي يجب أن يراها ضيوف عملائك.", + "legalNameNotPublished": "هذا هو اسمك القانوني، ولن يُعرض على موقع أي مطعم. تُحفظ بياناتك على أي حال — اكتب اسم علامة تجارية لتُنسب إليك المواقع.", "fields": { "displayName": "الاسم المعروض", "tagline": "شعار قصير (اختياري)", diff --git a/messages/de.json b/messages/de.json index 9e5d56f..d447d0f 100644 --- a/messages/de.json +++ b/messages/de.json @@ -1239,6 +1239,8 @@ "intro": "Der Name und die Kontaktdaten, die Gäste eines Restaurants als das Unternehmen hinter der Website sehen könnten. Sie geben sie hier ein, damit nur das öffentlich wird, was Sie auswählen.", "privacyNote": "Sie sind bewusst von Ihren Rechnungsdaten getrennt. Ihre Rechnungsadresse und Ihre Registernummer bleiben privat und werden niemandem außer uns gezeigt.", "prefillNote": "Wir haben Ihren Handelsnamen als Anfang eingetragen. Sonst wird nichts aus Ihren Rechnungsdaten übernommen — bitte geben Sie ein, was Gäste sehen dürfen.", + "noTradeNameNote": "Ihre Rechnungsdaten enthalten einen amtlichen Namen, aber keinen Handelsnamen. Einen Personennamen dürfen wir nicht auf der Website eines Restaurants veröffentlichen — bitte geben Sie die Marke ein, die die Gäste Ihrer Kunden sehen sollen.", + "legalNameNotPublished": "Das ist Ihr amtlicher Name; er wird auf keiner Restaurant-Website angezeigt. Ihre Angaben werden trotzdem gespeichert — geben Sie einen Markennamen ein, um genannt zu werden.", "fields": { "displayName": "Anzeigename", "tagline": "Slogan (optional)", diff --git a/messages/en.json b/messages/en.json index 1dcd5fb..0755dd0 100644 --- a/messages/en.json +++ b/messages/en.json @@ -1239,6 +1239,8 @@ "intro": "The name and contact details a restaurant's guests could be shown as the company behind their website. You type them here, so only what you choose is ever public.", "privacyNote": "These are kept separate from your billing details on purpose. Your invoice address and registration number stay private and are never shown to anyone but us.", "prefillNote": "We have filled in your trading name to start you off. Nothing else is copied from your billing details — please type what you are happy for guests to see.", + "noTradeNameNote": "Your billing details give a legal name but no trading name. We cannot publish a personal name on a restaurant's website, so please enter the brand your clients' guests should see.", + "legalNameNotPublished": "This is the same as your legal name, so it will not be shown on any restaurant's site. Your details are still saved — enter a brand name to be credited.", "fields": { "displayName": "Display name", "tagline": "Tagline (optional)", diff --git a/messages/fr.json b/messages/fr.json index 5e592a8..9cec320 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -1239,6 +1239,8 @@ "intro": "Le nom et les coordonnées que les clients d'un restaurant pourraient voir comme l'entreprise derrière leur site. Vous les saisissez ici, afin que seul ce que vous choisissez soit public.", "privacyNote": "Elles sont volontairement distinctes de vos données de facturation. Votre adresse de facturation et votre numéro d'enregistrement restent privés et ne sont montrés à personne d'autre que nous.", "prefillNote": "Nous avons prérempli votre nom commercial pour commencer. Rien d'autre n'est copié depuis vos données de facturation — saisissez ce que vous acceptez de montrer aux clients.", + "noTradeNameNote": "Vos données de facturation indiquent une raison sociale mais aucun nom commercial. Nous ne pouvons pas publier un nom de personne sur le site d'un restaurant : saisissez la marque que les clients de vos établissements doivent voir.", + "legalNameNotPublished": "C'est votre raison sociale : elle ne sera affichée sur le site d'aucun restaurant. Vos informations sont tout de même enregistrées — saisissez un nom de marque pour être crédité.", "fields": { "displayName": "Nom affiché", "tagline": "Slogan (facultatif)", diff --git a/messages/nl.json b/messages/nl.json index 9749f3c..8bf2a36 100644 --- a/messages/nl.json +++ b/messages/nl.json @@ -1239,6 +1239,8 @@ "intro": "De naam en contactgegevens die gasten van een restaurant kunnen zien als het bedrijf achter hun website. U typt ze hier, zodat alleen wat u kiest openbaar wordt.", "privacyNote": "Deze staan bewust los van uw factuurgegevens. Uw factuuradres en registratienummer blijven privé en worden aan niemand anders dan ons getoond.", "prefillNote": "We hebben uw handelsnaam alvast ingevuld. Verder wordt er niets uit uw factuurgegevens overgenomen — typ wat u gasten wilt laten zien.", + "noTradeNameNote": "Uw factuurgegevens bevatten wel een juridische naam maar geen handelsnaam. Een persoonsnaam mogen we niet op de website van een restaurant publiceren — vul het merk in dat de gasten van uw klanten moeten zien.", + "legalNameNotPublished": "Dit is uw juridische naam; die wordt op geen enkele restaurantsite getoond. Uw gegevens worden wel bewaard — vul een merknaam in om vermeld te worden.", "fields": { "displayName": "Weergavenaam", "tagline": "Slogan (optioneel)", diff --git a/messages/tr.json b/messages/tr.json index 82bdf75..863ca3b 100644 --- a/messages/tr.json +++ b/messages/tr.json @@ -1239,6 +1239,8 @@ "intro": "Bir restoranın misafirlerinin, sitelerinin arkasındaki şirket olarak görebileceği ad ve iletişim bilgileri. Yalnızca seçtikleriniz herkese açık olsun diye bunları burada siz yazarsınız.", "privacyNote": "Bunlar fatura bilgilerinizden bilerek ayrı tutulur. Fatura adresiniz ve sicil numaranız gizli kalır ve bizden başka kimseye gösterilmez.", "prefillNote": "Başlangıç olarak ticari adınızı doldurduk. Fatura bilgilerinizden başka hiçbir şey kopyalanmaz — misafirlerin görmesini istediğiniz bilgileri yazın.", + "noTradeNameNote": "Fatura bilgilerinizde yasal ad var ama ticari ad yok. Bir kişinin adını restoran web sitesinde yayımlayamayız; lütfen müşterilerinizin misafirlerinin göreceği marka adını girin.", + "legalNameNotPublished": "Bu, yasal adınızla aynı; hiçbir restoran sitesinde gösterilmeyecek. Bilgileriniz yine de kaydedilir — adınızın anılması için bir marka adı girin.", "fields": { "displayName": "Görünen ad", "tagline": "Slogan (isteğe bağlı)", diff --git a/tests/e2e/partner-brand.spec.ts b/tests/e2e/partner-brand.spec.ts index 038a536..8627cd1 100644 --- a/tests/e2e/partner-brand.spec.ts +++ b/tests/e2e/partner-brand.spec.ts @@ -115,3 +115,38 @@ test("a partner cannot write another partner's brand, even by naming them", asyn expect(await findPartnerBrand(mineId)).toMatchObject({ displayName: "My Brand" }); expect(await findPartnerBrand(theirsId)).toMatchObject({ displayName: "Their Brand" }); }); + +test("a sole trader is told why their own name cannot be published (D-B1a)", async ({ page }) => { + const email = uniq.email("brandsole"); + const partnerId = await arrangePartnerUser(email, PASSWORD); + // The case §11b is about: a legal record naming a PERSON, with no trade name. The + // prefill therefore offers that person's own name — correct for a field they are + // about to read and edit, and catastrophic if it were quietly published. + await arrangeBillingIdentityFor(partnerId, { + legalName: "Mustafa Vural", + tradeName: null, + addressLine1: "Chemin Privé 7", + }); + + await login(page, { email, password: PASSWORD }); + await page.waitForURL("**/dashboard"); + await page.goto("/dashboard/brand"); + + // Said before they type, not after they save. + await expect(page.getByText(/no trading name/i)).toBeVisible(); + await expect(page.getByText(/will not be shown on any restaurant/i)).toBeVisible(); + + // The save still SUCCEEDS — it is their record, and refusing the write would leave + // them unable to record anything at all. What is refused is publishing it, and the + // page says so rather than accepting the value and dropping it out of sight. + await page.getByRole("button", { name: /^save$/i }).click(); + await expect(page.getByText(/^saved\.$/i)).toBeVisible(); + expect(await findPartnerBrand(partnerId)).toMatchObject({ displayName: "Mustafa Vural" }); + + // Type a real brand and the warning goes; type the legal name back, in another + // case and with stray spaces, and it returns — the comparison is normalised. + await page.getByLabel(/display name/i).fill("Solution Eva"); + await expect(page.getByText(/will not be shown on any restaurant/i)).toHaveCount(0); + await page.getByLabel(/display name/i).fill(" mustafa VURAL "); + await expect(page.getByText(/will not be shown on any restaurant/i)).toBeVisible(); +}); diff --git a/tests/unit/partner-brand.test.ts b/tests/unit/partner-brand.test.ts index ca54390..bd0337f 100644 --- a/tests/unit/partner-brand.test.ts +++ b/tests/unit/partner-brand.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from "vitest"; import { checkboxOn, + isLegalNameEcho, partnerBrandSchema, prefillFromBillingIdentity, renderableBrand, @@ -177,22 +178,41 @@ describe("prefillFromBillingIdentity — one field crosses, and only one", () => }); describe("renderableBrand — the single door", () => { - it("passes a published brand through, field for field", () => { + // S3a TIGHTENED this projection to D-B1: `displayName` + `websiteUrl` and nothing + // else. The assertions that named tagline/email/phone/address/postcode/city/country + // were deliberately rewritten, which is the point of the direction — a footer that + // stops carrying a partner's postal address announces itself in red here, whereas + // adding those fields back would be silent and needs its own controls (D-B4). + it("publishes an ATTRIBUTION: a name and a link, and nothing else", () => { expect(renderableBrand(full)).toEqual({ - displayName: full.displayName, - tagline: full.tagline, - websiteUrl: full.websiteUrl, - email: full.email, - phone: full.phone, - addressLine1: full.addressLine1, - postalCode: full.postalCode, - city: full.city, - countryCode: full.countryCode, + displayName: "Solution Eva", + websiteUrl: "https://solutioneva.com", }); }); + it("carries no contact details at all, however full the record", () => { + const out = renderableBrand(full) as Record; + for (const key of ["tagline", "email", "phone", "addressLine1", "postalCode", "city", "countryCode"]) { + expect(out, `${key} must not be published`).not.toHaveProperty(key); + } + expect(Object.keys(out).sort()).toEqual(["displayName", "websiteUrl"]); + }); it("never leaks the flag itself into the projection", () => { expect(renderableBrand(full)).not.toHaveProperty("publishToTenants"); }); + it("omits websiteUrl entirely when there is none — absent, never null", () => { + const out = renderableBrand({ ...full, websiteUrl: null }); + expect(out).toEqual({ displayName: "Solution Eva" }); + expect(out).not.toHaveProperty("websiteUrl"); + }); + // Defence in depth: the write schema refuses these, so a row carrying one came from + // somewhere else (an older row, a future admin tool, a hand-edited database). The + // NAME still publishes — the partner asked for that — only the href is dropped. + it("drops a non-https link rather than the whole credit", () => { + for (const bad of ["javascript:alert(1)", "http://solutioneva.com", "solutioneva.com"]) { + expect(renderableBrand({ ...full, websiteUrl: bad })).toEqual({ displayName: "Solution Eva" }); + } + }); + // THE negative control. A record with every field filled in — the one a naive // caller would happily render — comes back as null purely because nobody opted // in. If this ever returns an object, a partner who never consented is in a @@ -204,4 +224,64 @@ describe("renderableBrand — the single door", () => { expect(renderableBrand(null)).toBeNull(); expect(renderableBrand(undefined)).toBeNull(); }); + it("returns null for a blank display name, whatever else is set", () => { + expect(renderableBrand({ ...full, displayName: " " })).toBeNull(); + }); +}); + +describe("renderableBrand — D-B1a: it refuses the legal name", () => { + // The catastrophic path this exists to close: `prefillFromBillingIdentity` offers + // `tradeName ?? legalName`, so a sole trader with no trade name is offered their own + // name, saves the form untouched, and would otherwise be published on a restaurant's + // page by a convenience nobody decided on. + const sole: StoredBrand = { ...full, displayName: "Mustafa Vural", websiteUrl: null }; + + it("refuses an exact match", () => { + expect(renderableBrand(sole, { legalName: "Mustafa Vural" })).toBeNull(); + }); + // The normalisation cases, and they are the realistic ones: a prefilled value that + // was retyped, pasted with a trailing space, or shouted. + it("refuses a match that differs only by whitespace and case", () => { + expect( + renderableBrand({ ...sole, displayName: " mustafa VURAL " }, { legalName: "Mustafa Vural" }), + ).toBeNull(); + }); + it("refuses when the LEGAL name is the untidy one", () => { + expect(renderableBrand(sole, { legalName: " MUSTAFA vural " })).toBeNull(); + }); + it("refuses the registry's own spelling of that partner", () => { + // SOFRA-BILLING-IDENTITY-PLAN §6 records the one live reseller as `VURAL Mustafa` + // trading as `SOLUTIONEVA` — the order is not the same, so this one must PASS. + expect(renderableBrand(sole, { legalName: "VURAL Mustafa" })).toEqual({ + displayName: "Mustafa Vural", + }); + }); + it("publishes a real brand belonging to the same person", () => { + expect(renderableBrand(full, { legalName: "Mustafa Vural" })).toEqual({ + displayName: "Solution Eva", + websiteUrl: "https://solutioneva.com", + }); + }); + it("does not fire when no legal name is known — the rule needs both records", () => { + expect(renderableBrand(sole)).toEqual({ displayName: "Mustafa Vural" }); + expect(renderableBrand(sole, { legalName: null })).toEqual({ displayName: "Mustafa Vural" }); + expect(renderableBrand(sole, { legalName: " " })).toEqual({ displayName: "Mustafa Vural" }); + }); +}); + +describe("isLegalNameEcho — the predicate the FORM shares with the choke point", () => { + it("is true for the same name written differently", () => { + expect(isLegalNameEcho(" mustafa VURAL ", "Mustafa Vural")).toBe(true); + }); + it("is false for a different name", () => { + expect(isLegalNameEcho("Solution Eva", "Mustafa Vural")).toBe(false); + }); + // Both halves blank would compare equal as "" — and would then hide a brand for a + // reason that has nothing to do with the legal record. + it("is false when either side is empty, rather than trivially true", () => { + expect(isLegalNameEcho("", "")).toBe(false); + expect(isLegalNameEcho(" ", null)).toBe(false); + expect(isLegalNameEcho(null, "Mustafa Vural")).toBe(false); + expect(isLegalNameEcho("Mustafa Vural", undefined)).toBe(false); + }); }); diff --git a/tests/unit/provisioning-registry.test.ts b/tests/unit/provisioning-registry.test.ts index 292233c..bd1fe2a 100644 --- a/tests/unit/provisioning-registry.test.ts +++ b/tests/unit/provisioning-registry.test.ts @@ -480,3 +480,94 @@ describe("the PR body describes the entry it ships with", () => { expect(body).not.toContain("dig +short"); }); }); + +describe("a partner credit in the generated entry (§11e, S3a)", () => { + // Same regression proof as `base_domain` above, and it is the important half of this + // block: every test in this file that predates `partnerBrand` still passes UNCHANGED. + // Absence is the contract — an entry with no credit is byte-identical to one built + // before the field existed, which is what every entry in the live registry is. + const withBrand = (partnerBrand?: { displayName: string; websiteUrl?: string }) => + asTenant( + buildTenantRegistryEntry({ + slug: "obresse", + name: "O'Bresse", + adminEmail: "chef@obresse.example", + template: "craft", + currency: "CHF", + languages: ["fr"], + modules: ["core"], + partnerBrand, + }), + "obresse", + ) as Record; + + it("emits partner_name and partner_url as flat keys", () => { + // Flat, not a nested `partner:` map: `provision-tenant.sh` reads a fixed key list + // and flattens each value with `str(v)`, so a map would arrive in the shell as a + // stringified Python dict (plan §11d2). + const t = withBrand({ displayName: "Solution Eva", websiteUrl: "https://solutioneva.com" }); + expect(t.partner_name).toBe("Solution Eva"); + expect(t.partner_url).toBe("https://solutioneva.com"); + }); + + it("emits the name alone when the partner recorded no website", () => { + const t = withBrand({ displayName: "Solution Eva" }); + expect(t.partner_name).toBe("Solution Eva"); + expect(t).not.toHaveProperty("partner_url"); + }); + + // THE absence case. Not "partner_name is empty" — the key must not be there at all, + // because an empty value is a thing someone set and an absent key is the default the + // deploy script already implements. + it("emits NEITHER key when there is no publishable brand", () => { + const t = withBrand(); + expect(t).not.toHaveProperty("partner_name"); + expect(t).not.toHaveProperty("partner_url"); + }); + + // D-B2: absent means attribution is ON, so writing the key on every entry would be a + // no-op line on all of them. It is the RESTAURANT's switch, hand-added by the founder + // on their behalf, and `provision-tenant.sh` is where the boolean is resolved. + it("never emits partner_attribution, credited or not", () => { + expect(withBrand({ displayName: "Solution Eva" })).not.toHaveProperty("partner_attribution"); + expect(withBrand()).not.toHaveProperty("partner_attribution"); + }); + + it("escapes a brand name that would otherwise break the YAML", () => { + const t = withBrand({ displayName: "Eva: #1 \"partner\"\nname: pwned" }); + expect(t.partner_name).toBe("Eva: #1 \"partner\"\nname: pwned"); + expect(t.name).toBe("O'Bresse"); + }); + + it("says so in the PR body — the founder's review checkpoint (ADR-012)", () => { + const input = { + slug: "obresse", + name: "O'Bresse", + adminEmail: "chef@obresse.example", + template: "craft" as const, + currency: "CHF", + languages: ["fr"], + modules: ["core"], + partnerBrand: { displayName: "Solution Eva", websiteUrl: "https://solutioneva.com" }, + }; + const body = buildProvisioningPrBody(input); + expect(body).toContain("Solution Eva"); + expect(body).toContain("https://solutioneva.com"); + // The line that makes the section actionable: the founder is the only party who can + // turn it off on the restaurant's behalf, and the key only ever appears to do that. + expect(body).toContain("partner_attribution: false"); + // …and stays silent otherwise, rather than printing an empty section. + const quiet = buildProvisioningPrBody({ ...input, partnerBrand: undefined }); + expect(quiet).not.toContain("partner_attribution"); + expect(quiet).not.toContain("Solution Eva"); + + // A credit with no link says so, rather than describing a link the entry has + // not got: the founder is being asked what the footer will read. + const unlinked = buildProvisioningPrBody({ + ...input, + partnerBrand: { displayName: "Solution Eva" }, + }); + expect(unlinked).toContain("no link"); + expect(unlinked).not.toContain("https://solutioneva.com"); + }); +}); diff --git a/vitest.config.ts b/vitest.config.ts index 1d1eaac..92728af 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -44,6 +44,10 @@ export default defineConfig({ // have quietly moved already-covered code out of the floor's scope, which reads // as a passing gate rather than as lost coverage. "lib/provisioning-pr-body.ts", + // Its conditional sections, split out for the same limit and listed for the + // same reason. Every branch here is a warning the founder either gets or does + // not get at the one reviewable moment before a tenant is stood up. + "lib/provisioning-pr-blocks.ts", "lib/module-catalog.ts", "lib/tenant-options.ts", "lib/signup-configuration.ts", @@ -115,6 +119,11 @@ export default defineConfig({ // looks like a footer with a name in it. The https-only and ISO-country // refusals are decidable here too, and nowhere cheaper. "lib/partner-brand.ts", + // The publish half, split out of it for the LOC limit. Listed explicitly for + // the reason provisioning-pr-body.ts is: leaving it off would quietly move + // already-covered code OUT of the floor's scope, which reads as a passing gate + // rather than as lost coverage — and this is the file holding the refusals. + "lib/partner-brand-publish.ts", "lib/base-domain-verification.ts", // D2 — which of the four domain shapes a partner proposed, and what DNS it // needs. Pure by construction: it cannot see whose base domain it was handed or From 37e5388cb493de04072971229cabe46d55ba733d Mon Sep 17 00:00:00 2001 From: mahmutkaya Date: Wed, 2 Sep 2026 19:25:26 +0200 Subject: [PATCH 3/3] fix(deps): clear three high advisories blocking CI on develop MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `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 --- package-lock.json | 107 +++++++++++++++++++++------------------------- package.json | 6 ++- 2 files changed, 53 insertions(+), 60 deletions(-) diff --git a/package-lock.json b/package-lock.json index 3cadab9..7c808d8 100644 --- a/package-lock.json +++ b/package-lock.json @@ -5487,9 +5487,9 @@ } }, "node_modules/baseline-browser-mapping": { - "version": "2.10.42", - "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.42.tgz", - "integrity": "sha512-c/jurFrDLyui7o1J86yLkRu4LMsTYcBohveus7/I2Hzdn9KIP2bdJPTue/lR1KH46enoPbD77GKeSYNdyPoD3Q==", + "version": "2.11.20", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.11.20.tgz", + "integrity": "sha512-H0ulySigv6icDJ1F7SjtdCD6PrhTpdYCmP0CactWy1+ekh0AFd0o1Wn5T8b+hnTmdBx19u9yhL6wvCylXMY7zw==", "license": "Apache-2.0", "bin": { "baseline-browser-mapping": "dist/cli.cjs" @@ -5540,9 +5540,9 @@ } }, "node_modules/browserslist": { - "version": "4.28.4", - "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.4.tgz", - "integrity": "sha512-MTc8i/x9jBQd1iMw2CFGS+rwMa07eYjLR0CCTLDACl9xhxy+nIs3KeML/biicXtk9JrZ6dnnTatmc7ErPXIxqw==", + "version": "4.28.8", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.8.tgz", + "integrity": "sha512-V2NpofLblG64mfOtSgDhOJESZEGogzDMBv/q+W6oc4LXWP/q75eOXoOaaOu1EOadB9U4Bwx/e0yzbvwKH8zalA==", "funding": [ { "type": "opencollective", @@ -5559,11 +5559,11 @@ ], "license": "MIT", "dependencies": { - "baseline-browser-mapping": "^2.10.38", - "caniuse-lite": "^1.0.30001799", - "electron-to-chromium": "^1.5.376", - "node-releases": "^2.0.48", - "update-browserslist-db": "^1.2.3" + "baseline-browser-mapping": "^2.11.12", + "caniuse-lite": "^1.0.30001809", + "electron-to-chromium": "^1.5.402", + "node-releases": "^2.0.53", + "update-browserslist-db": "^1.3.0" }, "bin": { "browserslist": "cli.js" @@ -5669,9 +5669,9 @@ } }, "node_modules/caniuse-lite": { - "version": "1.0.30001800", - "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001800.tgz", - "integrity": "sha512-MMHtuAz9Ys840zAY5F4k6fV5GaivZ9sPk+nz0mY+GYVzRBnYkN0mpqkSR92oWRQ19yQWo4HvBV/FnC16AJX8MA==", + "version": "1.0.30001810", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001810.tgz", + "integrity": "sha512-TITQPUkaz+aVk5GL6NhOdwk1aEaNTSDPsGFWrTuhKGtjTF70jL/Oht2W4c6rXUe5fu7Ie19VIahAXHIIiWWNeg==", "funding": [ { "type": "opencollective", @@ -5975,16 +5975,6 @@ "devOptional": true, "license": "MIT" }, - "node_modules/denque": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/denque/-/denque-2.1.0.tgz", - "integrity": "sha512-HVQE3AAb/pxF8fQAoiqpvg9i3evqug3hoiwakOyZAwJm+6vZehbkYXZ0l4JxS+I3QxM97v5aaRNhj8v5oBhekw==", - "devOptional": true, - "license": "Apache-2.0", - "engines": { - "node": ">=0.10" - } - }, "node_modules/destr": { "version": "2.0.5", "resolved": "https://registry.npmjs.org/destr/-/destr-2.0.5.tgz", @@ -6054,9 +6044,9 @@ } }, "node_modules/electron-to-chromium": { - "version": "1.5.387", - "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.387.tgz", - "integrity": "sha512-TaxwufTFDufvPEoXdhwVrA3UdFWBeWGkYoJ1K8ldF1xe6gKfth6iRNS5lTQ5JPNOHdGQm8PT1QYKUqFLCiUefQ==", + "version": "1.5.420", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.420.tgz", + "integrity": "sha512-2yD6XreGusOfNV+dUcvipJEXc3n/n7fgr7996aszTG+YY5E4mqM4tOq/3uhP129cazL9YHbVWSpc79ePotWtPA==", "license": "ISC" }, "node_modules/emoji-regex": { @@ -6887,9 +6877,9 @@ "license": "MIT" }, "node_modules/fast-uri": { - "version": "3.1.5", - "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.5.tgz", - "integrity": "sha512-gHwA1O9LDIcKunMKhObS/HimwtehO1nPUECKAu5TpKgaO19fcWEl4bliWe1jWxVFvIXztJjjQ4L8XQ1EU9f7Jw==", + "version": "3.1.7", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.7.tgz", + "integrity": "sha512-dOvZVzjdZdz7phd9v6jCbwxrBW3fK6n8Rc0CtdmM4bumzMnxywBYhuph6J819RRw/ku+rLbelwfMunktuzVVHg==", "funding": [ { "type": "github", @@ -8789,24 +8779,25 @@ "license": "MIT" }, "node_modules/mysql2": { - "version": "3.15.3", - "resolved": "https://registry.npmjs.org/mysql2/-/mysql2-3.15.3.tgz", - "integrity": "sha512-FBrGau0IXmuqg4haEZRBfHNWB5mUARw6hNwPDXXGg0XzVJ50mr/9hb267lvpVMnhZ1FON3qNd4Xfcez1rbFwSg==", + "version": "3.24.3", + "resolved": "https://registry.npmjs.org/mysql2/-/mysql2-3.24.3.tgz", + "integrity": "sha512-OKfWHkMAg9v06neq8FmSyhbxPQKABN9PAW5G9/bDTXzJBO5xXtkKL0V27vju7HQWk9UD4Od5BZsvCtBTB1CPEw==", "devOptional": true, "license": "MIT", "dependencies": { - "aws-ssl-profiles": "^1.1.1", - "denque": "^2.1.0", + "aws-ssl-profiles": "^1.1.2", "generate-function": "^2.3.1", - "iconv-lite": "^0.7.0", - "long": "^5.2.1", - "lru.min": "^1.0.0", - "named-placeholders": "^1.1.3", - "seq-queue": "^0.0.5", - "sqlstring": "^2.3.2" + "iconv-lite": "^0.7.3", + "long": "^5.3.2", + "lru.min": "^1.1.4", + "named-placeholders": "^1.1.6", + "sql-escaper": "^1.5.1" }, "engines": { "node": ">= 8.0" + }, + "peerDependencies": { + "@types/node": ">= 8" } }, "node_modules/named-placeholders": { @@ -9102,9 +9093,9 @@ } }, "node_modules/node-releases": { - "version": "2.0.50", - "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.50.tgz", - "integrity": "sha512-J6l92tKHX6w8Jy5nO1Vuc01NoIiRGi/d6qBKVxh+IQ8Cr3b6HbVNfKiF8ZpFKufTwpwxMmce2W3iQZ861ZRyTg==", + "version": "2.0.54", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.54.tgz", + "integrity": "sha512-YHs7BmmcsdAI5Ozuf8JZo6PT0mv2GIWC9vMfvUC3dp65M8hn7Ux8CPL+2oBI7juNuj9d0ndhTcznq2ODBps9cQ==", "license": "MIT", "engines": { "node": ">=18" @@ -10215,12 +10206,6 @@ "node": ">=10" } }, - "node_modules/seq-queue": { - "version": "0.0.5", - "resolved": "https://registry.npmjs.org/seq-queue/-/seq-queue-0.0.5.tgz", - "integrity": "sha512-hr3Wtp/GZIc/6DAGPDcV4/9WoZhjrkXsi5B/07QgX8tsdc6ilr7BFM6PM6rbdAX1kFSDYeZGLipIZZKyQP0O5Q==", - "devOptional": true - }, "node_modules/set-function-length": { "version": "1.2.2", "resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz", @@ -10477,14 +10462,20 @@ "node": ">= 10.x" } }, - "node_modules/sqlstring": { - "version": "2.3.3", - "resolved": "https://registry.npmjs.org/sqlstring/-/sqlstring-2.3.3.tgz", - "integrity": "sha512-qC9iz2FlN7DQl3+wjwn3802RTyjCx7sDvfQEXchwa6CWOx07/WVfh91gBmQ9fahw8snwGEWU3xGzOt4tFyHLxg==", + "node_modules/sql-escaper": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/sql-escaper/-/sql-escaper-1.5.1.tgz", + "integrity": "sha512-4toX5E1fQbBrpfXidaHnF0669nkAdETeIPTs2SUjxxD7RRIs9ICG4gtpmfc68JCEKehsdwLFqBu9VlQqZ1P1gg==", "devOptional": true, "license": "MIT", "engines": { - "node": ">= 0.6" + "bun": ">=1.0.0", + "deno": ">=2.0.0", + "node": ">=12.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/mysqljs/sql-escaper?sponsor=1" } }, "node_modules/stable-hash": { @@ -11039,9 +11030,9 @@ } }, "node_modules/update-browserslist-db": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", - "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==", + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.3.2.tgz", + "integrity": "sha512-UQ+MSxlhRm1bzjhU+DcuXfjFO1FzNtqhK5+9Yvlp90ItDLk5vT932A0rFu619nf7RVS+Y/VeaUW1jaRDqZ8VJw==", "funding": [ { "type": "opencollective", diff --git a/package.json b/package.json index 94bf1a6..277780c 100644 --- a/package.json +++ b/package.json @@ -52,10 +52,12 @@ "deepmerge-ts": "^8.0.1", "postcss": "^8.5.23", "@hono/node-server": "^2.0.5", - "fast-uri": "^3.1.4", + "fast-uri": "^3.1.6", "sharp": "^0.35.0", "brace-expansion": "^5.0.8", "@auth/core": "^0.41.3", - "valibot": "^1.4.2" + "valibot": "^1.4.2", + "mysql2": "^3.22.0", + "browserslist": "^4.28.7" } }